Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions apps/pocket-music/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Show, createSignal } from "solid-js";
import { Text, View } from "@pocketjs/framework/components";
import { BTN } from "@pocketjs/framework/input";
import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle";
import {
connectPocketMusic,
type PocketMusicOperation,
type PocketMusicState,
} from "./service.ts";

const OFFLINE: PocketMusicState = {
daemonConnected: false,
deviceConnected: false,
playerRunning: false,
playing: false,
positionMs: 0,
volume: 0,
sequence: 0,
};

function timeLabel(milliseconds: number): string {
const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}

function statusLabel(state: PocketMusicState): string {
if (!state.daemonConnected) return "START DAEMON";
if (!state.deviceConnected) return "CONNECT iPOD";
if (!state.playerRunning) return "OPEN MUSIC";
return state.playing ? "PLAYING" : "PAUSED";
}

export default function PocketMusic() {
const connection = connectPocketMusic();
const [state, setState] = createSignal(OFFLINE);

const send = (op: PocketMusicOperation): void => connection?.send(op);
onButtonPress(BTN.START, () => send("toggle"));
onButtonPress(BTN.RIGHT, () => send("next"));
onButtonPress(BTN.LEFT, () => send("previous"));
onButtonPress(BTN.TRIANGLE, () => send("stop"));
onButtonPress(BTN.CIRCLE, () => send("mute"));
onButtonPress(BTN.DOWN, () => send("volume-up"));
onButtonPress(BTN.UP, () => send("volume-down"));

onFrame(() => {
if (!connection) return;
for (const next of connection.poll()) setState(next);
});

const duration = () => state().track?.durationMs ?? 0;
const progress = () =>
duration() > 0 ? Math.min(1, state().positionMs / duration()) : 0;

return (
<View class="w-full h-full flex-col bg-[#e9e9e9] overflow-hidden">
<View class="h-[18] flex-row items-center px-[5] bg-gradient-to-b from-[#fafafa] to-[#bdbdbd]">
<Text class="w-[24] text-xs text-[#1c1c1c] font-bold">{state().playing ? ">" : "II"}</Text>
<View class="flex-1 items-center">
<Text class="text-xs text-[#161616] font-bold">Pocket Music</Text>
</View>
<Text class="w-[24] text-xs text-[#1c1c1c]">{state().deviceConnected ? "iP" : "--"}</Text>
</View>

<Show
when={state().track}
fallback={
<View class="flex-1 flex-col items-center justify-center px-[8]">
<Text class="text-xs text-[#1c1c1c] font-bold">{statusLabel(state())}</Text>
<Text class="text-xs text-[#666666]">Rockbox USB HID</Text>
</View>
}
>
{(track) => (
<View class="flex-1 flex-col px-[8] pt-[5] pb-[3]">
<View class="h-[19] overflow-hidden">
<Text class="text-xs text-[#111111] font-bold">{track().title}</Text>
</View>
<View class="h-[15] overflow-hidden">
<Text class="text-xs text-[#343434]">{track().artist}</Text>
</View>
<View class="h-[15] overflow-hidden">
<Text class="text-xs text-[#666666]">{track().album || "Music"}</Text>
</View>
<View class="h-[8] mt-[3] p-[1] border-[#858585] bg-[#d0d0d0] overflow-hidden">
<View
class="w-[156] h-[4] bg-gradient-to-r from-[#65b5ef] to-[#1473bd]"
style={{
scaleX: progress(),
translateX: -(156 * (1 - progress())) / 2,
}}
/>
</View>
<View class="h-[15] flex-row justify-between">
<Text class="text-xs text-[#444444]">{timeLabel(state().positionMs)}</Text>
<Text class="text-xs text-[#444444]">-{timeLabel(duration() - state().positionMs)}</Text>
</View>
</View>
)}
</Show>

<View class="h-[20] flex-row items-center px-[6] bg-gradient-to-b from-[#d5d5d5] to-[#a9a9a9]">
<Text class="w-[32] text-xs text-[#222222] font-bold">VOL</Text>
<View class="w-[102] h-[7] p-[1] border-[#707070] bg-[#eeeeee] overflow-hidden">
<View
class="w-[98] h-[3] bg-[#2e83c7]"
style={{
scaleX: state().volume / 100,
translateX: -(98 * (1 - state().volume / 100)) / 2,
}}
/>
</View>
<Text class="flex-1 text-xs text-[#222222] font-bold">{Math.round(state().volume)}</Text>
</View>
</View>
);
}
5 changes: 5 additions & 0 deletions apps/pocket-music/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// @title Pocket Music
import { mount } from "@pocketjs/framework";
import PocketMusic from "./app.tsx";

mount(() => <PocketMusic />);
30 changes: 30 additions & 0 deletions apps/pocket-music/pocket.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"$schema": "https://pocketjs.dev/schema/pocket-2.json",
"pocket": 2,
"id": "dev.pocket-stack.pocket-music",
"name": "pocket-music",
"title": "Pocket Music",
"version": "0.1.0",
"engine": {
"capabilities": {
"requires": [
"text.glyphs.baked",
"input.buttons"
]
}
},
"app": {
"entry": "apps/pocket-music/main.tsx",
"output": "pocket-music-main",
"framework": "solid",
"viewport": {
"fixed": {
"logical": [
176,
132
],
"presentation": "native"
}
}
}
}
134 changes: 134 additions & 0 deletions apps/pocket-music/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { getOps } from "@pocketjs/framework";

export const POCKET_MUSIC_SERVICE = "pocket-music";

export type PocketMusicOperation =
| "toggle"
| "next"
| "previous"
| "stop"
| "mute"
| "volume-up"
| "volume-down";

export interface PocketMusicTrack {
readonly id: string;
readonly title: string;
readonly artist: string;
readonly album: string;
readonly durationMs: number;
}

export interface PocketMusicState {
readonly daemonConnected: boolean;
readonly deviceConnected: boolean;
readonly playerRunning: boolean;
readonly playing: boolean;
readonly positionMs: number;
readonly volume: number;
readonly sequence: number;
readonly track?: PocketMusicTrack;
readonly lastControl?: string;
readonly error?: string;
}

export interface PocketMusicServiceOps {
svcOpen?(app: string): boolean;
svcPoll?(): string | undefined;
svcSend?(line: string): void;
}

export interface PocketMusicConnection {
poll(): PocketMusicState[];
send(op: PocketMusicOperation): void;
}

function nonNegative(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}

function parseTrack(value: unknown): PocketMusicTrack | undefined | null {
if (value === undefined) return undefined;
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const track = value as Record<string, unknown>;
if (
typeof track.id !== "string" ||
typeof track.title !== "string" ||
typeof track.artist !== "string" ||
typeof track.album !== "string" ||
!nonNegative(track.durationMs)
) {
return null;
}
return {
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
durationMs: track.durationMs,
};
}

export function parsePocketMusicState(line: string): PocketMusicState | null {
let value: unknown;
try {
value = JSON.parse(line);
} catch {
return null;
}
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const event = value as Record<string, unknown>;
if (event.t !== "pocket-music.state") return null;
if (
typeof event.daemonConnected !== "boolean" ||
typeof event.deviceConnected !== "boolean" ||
typeof event.playerRunning !== "boolean" ||
typeof event.playing !== "boolean" ||
!nonNegative(event.positionMs) ||
!nonNegative(event.volume) ||
event.volume > 100 ||
!Number.isInteger(event.sequence) ||
(event.sequence as number) < 0
) {
return null;
}
const track = parseTrack(event.track);
if (track === null) return null;
if (event.lastControl !== undefined && typeof event.lastControl !== "string") return null;
if (event.error !== undefined && typeof event.error !== "string") return null;
return {
daemonConnected: event.daemonConnected,
deviceConnected: event.deviceConnected,
playerRunning: event.playerRunning,
playing: event.playing,
positionMs: event.positionMs,
volume: event.volume,
sequence: event.sequence as number,
...(track === undefined ? {} : { track }),
...(event.lastControl === undefined ? {} : { lastControl: event.lastControl as string }),
...(event.error === undefined ? {} : { error: event.error as string }),
};
}

export function parsePocketMusicBatch(batch: string | undefined): PocketMusicState[] {
if (!batch) return [];
const states: PocketMusicState[] = [];
for (const line of batch.split("\n")) {
const state = parsePocketMusicState(line);
if (state) states.push(state);
}
return states;
}

export function connectPocketMusic(
ops: PocketMusicServiceOps = getOps(),
): PocketMusicConnection | null {
if (!ops.svcOpen || !ops.svcPoll || !ops.svcSend) return null;
if (!ops.svcOpen(POCKET_MUSIC_SERVICE)) return null;
const poll = ops.svcPoll.bind(ops);
const send = ops.svcSend.bind(ops);
return {
poll: () => parsePocketMusicBatch(poll()),
send: (op) => send(JSON.stringify({ t: "pocket-music.command", op })),
};
}
Loading