diff --git a/apps/app-proxy/src/index.ts b/apps/app-proxy/src/index.ts index f6d42618..9afdf6af 100644 --- a/apps/app-proxy/src/index.ts +++ b/apps/app-proxy/src/index.ts @@ -90,18 +90,21 @@ export default { return new Response(null, { status: 204, headers: corsHeaders(origin) }); } - // Serve Rockbox WASM assets from R2 with COOP/COEP headers required for SharedArrayBuffer - if (url.pathname.toLowerCase().includes('rockbox')) { - const baseUrl = (env as any).ROCKBOX_ASSETS_URL as string; - const assetUrl = `${baseUrl.replace(/\/$/, '')}${url.pathname}`; - const assetRes = await fetch(assetUrl); + // Serve rockbox-wasm runtime assets (audio worklet + decoder worker + the + // wasm-embedded core) from the web deployment, where the Vite build ships + // them at /rockbox/*. The current package is single-threaded with the wasm + // embedded (Emscripten SINGLE_FILE), so it needs NO SharedArrayBuffer and + // NO COOP/COEP — and the assets are versioned with the app rather than a + // separately-maintained CDN/R2 bucket. Match the /rockbox/ path prefix + // only (not any path merely containing "rockbox", e.g. the + // app.rocksky.rockbox.* XRPC endpoints). + if (url.pathname.startsWith('/rockbox/')) { + const assetUrl = new URL(request.url); + assetUrl.host = 'rocksky.pages.dev'; + assetUrl.hostname = 'rocksky.pages.dev'; + const assetRes = await fetch(assetUrl, request); const headers = new Headers(assetRes.headers); - headers.set('Cross-Origin-Opener-Policy', 'same-origin'); - headers.set('Cross-Origin-Embedder-Policy', 'credentialless'); headers.set('Access-Control-Allow-Origin', '*'); - if (url.pathname.endsWith('.wasm')) { - headers.set('Content-Type', 'application/wasm'); - } return new Response(assetRes.body, { status: assetRes.status, headers }); } @@ -169,8 +172,6 @@ export default { if (!og) return htmlRes; const headers = new Headers(htmlRes.headers); headers.set('cache-control', 'public, max-age=300'); - headers.set('Cross-Origin-Opener-Policy', 'same-origin'); - headers.set('Cross-Origin-Embedder-Policy', 'credentialless'); const rewritten = new HTMLRewriter() .on('meta[property^="og:"]', new StripMeta()) @@ -194,8 +195,6 @@ export default { const headers = new Headers(htmlRes.headers); headers.set('cache-control', 'public, max-age=300'); - headers.set('Cross-Origin-Opener-Policy', 'same-origin'); - headers.set('Cross-Origin-Embedder-Policy', 'credentialless'); const rewritten = new HTMLRewriter() .on('meta[property^="og:"]', new StripMeta()) diff --git a/apps/app-proxy/wrangler.json b/apps/app-proxy/wrangler.json index 0f843168..c4daba30 100644 --- a/apps/app-proxy/wrangler.json +++ b/apps/app-proxy/wrangler.json @@ -27,7 +27,6 @@ * Environment Variables * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables */ - "vars": { "ROCKBOX_ASSETS_URL": "https://cdn.rocksky.app" }, /** * Note: Use secrets to store sensitive data. * https://developers.cloudflare.com/workers/configuration/secrets/ diff --git a/apps/web-mobile/.gitignore b/apps/web-mobile/.gitignore index 961e460b..48f1eb3b 100644 --- a/apps/web-mobile/.gitignore +++ b/apps/web-mobile/.gitignore @@ -30,4 +30,6 @@ dist-ssr *storybook.log .env -.env.prod \ No newline at end of file +.env.prod +# rockbox-wasm runtime assets (copied from node_modules by vite.config.ts) +public/rockbox diff --git a/apps/web-mobile/package.json b/apps/web-mobile/package.json index 44ab5f53..57873b50 100644 --- a/apps/web-mobile/package.json +++ b/apps/web-mobile/package.json @@ -22,7 +22,6 @@ "@tabler/icons-react": "^3.31.0", "@tanstack/react-query": "^5.76.0", "@types/numeral": "^2.0.5", - "audio-decode": "^3.10.1", "axios": "^1.7.9", "baseui": "15.0.0", "consola": "^3.2.3", @@ -39,6 +38,7 @@ "react-hook-form": "^7.54.2", "react-router-dom": "^7.6.3", "recharts": "^2.15.1", + "rockbox-wasm": "^0.1.3", "styletron-engine-monolithic": "^1.0.0", "styletron-react": "^6.1.1", "tailwindcss": "^4.0.0", diff --git a/apps/web-mobile/src/atoms/equalizer.ts b/apps/web-mobile/src/atoms/equalizer.ts index fa578db7..cbd412de 100644 --- a/apps/web-mobile/src/atoms/equalizer.ts +++ b/apps/web-mobile/src/atoms/equalizer.ts @@ -5,22 +5,31 @@ export interface EqBandSetting { gain: number; // dB, clamped to [-24, +24] } +// The 10 EQ band centre frequencies (32 Hz … 16 kHz). Shared canonical set — +// index-aligned with the EQ UI and the wasm engine. Matches atradio.fm. +export const EQ_BANDS_HZ = [ + 32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000, +] as const; + // 10-band EQ: band 0 = low-shelf, bands 1-8 = peaking, band 9 = high-shelf -export const EQ_BANDS: EqBandSetting[] = [ - { cutoff: 60, gain: 0 }, - { cutoff: 200, gain: 0 }, - { cutoff: 500, gain: 0 }, - { cutoff: 1000, gain: 0 }, - { cutoff: 2000, gain: 0 }, - { cutoff: 4000, gain: 0 }, - { cutoff: 7000, gain: 0 }, - { cutoff: 10000, gain: 0 }, - { cutoff: 14000, gain: 0 }, - { cutoff: 20000, gain: 0 }, -]; +export const EQ_BANDS: EqBandSetting[] = EQ_BANDS_HZ.map((cutoff) => ({ + cutoff, + gain: 0, +})); -// Q=7.0 matches the Rockbox firmware default (stored as tenths: 70 → 7.0). +// Q=7.0 matches the Rockbox firmware default. export const EQ_Q = 7.0; export const eqEnabledAtom = atomWithStorage("eq_enabled", false); export const eqBandsAtom = atomWithStorage("eq_bands", EQ_BANDS); + +// ── Crossfade (rockbox pcmbuf algorithm, run in the wasm engine) ───────────── +export const crossfadeEnabledAtom = atomWithStorage( + "crossfade_enabled", + false, +); +// Fade in/out duration in seconds (Rockbox range 0–15 s). +export const crossfadeDurationAtom = atomWithStorage( + "crossfade_duration", + 2, +); diff --git a/apps/web-mobile/src/atoms/queue.ts b/apps/web-mobile/src/atoms/queue.ts index 195faf11..b0924693 100644 --- a/apps/web-mobile/src/atoms/queue.ts +++ b/apps/web-mobile/src/atoms/queue.ts @@ -10,6 +10,7 @@ export interface QueueTrack { duration: number; sha256: string; songUri: string; + streamUrl?: string; } export const queueAtom = atom([]); diff --git a/apps/web-mobile/src/components/EqualizerSheet/index.tsx b/apps/web-mobile/src/components/EqualizerSheet/index.tsx index da327a1a..eca444bc 100644 --- a/apps/web-mobile/src/components/EqualizerSheet/index.tsx +++ b/apps/web-mobile/src/components/EqualizerSheet/index.tsx @@ -1,10 +1,123 @@ import { IconX } from "@tabler/icons-react"; +import { Slider } from "baseui/slider"; import { useAtom } from "jotai"; import { useCallback, useMemo } from "react"; -import { eqBandsAtom, eqEnabledAtom, EQ_BANDS, type EqBandSetting } from "../../atoms/equalizer"; +import { + crossfadeDurationAtom, + crossfadeEnabledAtom, + eqBandsAtom, + eqEnabledAtom, + EQ_BANDS, + EQ_BANDS_HZ, + type EqBandSetting, +} from "../../atoms/equalizer"; function formatFreq(hz: number) { - return hz >= 1000 ? `${hz / 1000}k` : `${hz}`; + return hz >= 1000 ? `${hz / 1000}kHz` : `${hz}`; +} + +// Compact, on-brand overrides for the baseweb Slider (crossfade duration). +const SLIDER_OVERRIDES = { + Root: { style: { flexGrow: 1 } }, + Track: { + style: { + paddingLeft: 0, + paddingRight: 0, + paddingTop: "8px", + paddingBottom: "8px", + }, + }, + InnerTrack: { + style: { + height: "6px", + borderTopLeftRadius: "3px", + borderTopRightRadius: "3px", + borderBottomLeftRadius: "3px", + borderBottomRightRadius: "3px", + background: "var(--color-border)", + }, + }, + Thumb: { + style: ({ $disabled }: { $disabled?: boolean }) => ({ + height: "16px", + width: "16px", + borderTopLeftRadius: "50%", + borderTopRightRadius: "50%", + borderBottomLeftRadius: "50%", + borderBottomRightRadius: "50%", + backgroundColor: "var(--color-primary)", + borderWidth: 0, + opacity: $disabled ? 0.35 : 1, + }), + }, + InnerThumb: { style: { display: "none" } }, + ThumbValue: { style: { display: "none" } }, + Tick: { style: { display: "none" } }, + TickBar: { style: { display: "none" } }, +}; + +// EQ band faders use native range inputs — baseweb's Slider is horizontal-only +// (can't render the vertical portrait faders) and too chunky for a dense +// 10-band grid. baseweb's Slider IS used for the horizontal crossfade below. + +/** One vertical EQ band fader (portrait, gain in dB). */ +function VertBandSlider(props: { + value: number; + disabled: boolean; + onChange: (v: number) => void; + "aria-label": string; +}) { + return ( + props.onChange(Number(e.target.value))} + style={{ + appearance: "none", + WebkitAppearance: "slider-vertical", + writingMode: "vertical-lr", + direction: "rtl", + width: 20, + height: 120, + cursor: props.disabled ? "not-allowed" : "pointer", + accentColor: "var(--color-primary)", + opacity: props.disabled ? 0.35 : 1, + } as React.CSSProperties} + /> + ); +} + +/** One horizontal EQ band fader (landscape layout, gain in dB). */ +function HorizBandSlider(props: { + value: number; + disabled: boolean; + onChange: (v: number) => void; + "aria-label": string; +}) { + return ( + props.onChange(Number(e.target.value))} + className="flex-1" + style={{ + accentColor: "var(--color-primary)", + opacity: props.disabled ? 0.35 : 1, + cursor: props.disabled ? "not-allowed" : "pointer", + height: 4, + }} + /> + ); } function EqCurve({ bands, enabled }: { bands: EqBandSetting[]; enabled: boolean }) { @@ -63,6 +176,8 @@ type Props = { open: boolean; onClose: () => void }; export default function EqualizerSheet({ open, onClose }: Props) { const [enabled, setEnabled] = useAtom(eqEnabledAtom); const [bands, setBands] = useAtom(eqBandsAtom); + const [crossfadeEnabled, setCrossfadeEnabled] = useAtom(crossfadeEnabledAtom); + const [crossfadeDuration, setCrossfadeDuration] = useAtom(crossfadeDurationAtom); const setBandGain = useCallback((index: number, gain: number) => { setBands((prev) => prev.map((b, i) => i === index ? { ...b, gain } : b)); @@ -142,7 +257,7 @@ export default function EqualizerSheet({ open, onClose }: Props) { {/* Sliders — portrait: vertical, landscape: horizontal rows */} -
+
{/* dB labels column */}
+24 @@ -155,36 +270,22 @@ export default function EqualizerSheet({ open, onClose }: Props) { {band.gain > 0 ? `+${band.gain}` : band.gain}
- setBandGain(i, Number(e.target.value))} - style={{ - appearance: "none", - WebkitAppearance: "slider-vertical", - writingMode: "vertical-lr", - direction: "rtl", - width: 20, - height: 120, - cursor: enabled ? "pointer" : "not-allowed", - accentColor: "var(--color-primary)", - opacity: enabled ? 1 : 0.35, - } as React.CSSProperties} + onChange={(v) => setBandGain(i, v)} />
- {formatFreq(band.cutoff)} + {formatFreq(EQ_BANDS_HZ[i] ?? band.cutoff)}
))}
{/* Landscape layout — horizontal sliders in a grid */} -
+
{/* dB axis labels */}
-24dB @@ -197,23 +298,13 @@ export default function EqualizerSheet({ open, onClose }: Props) { {bands.map((band, i) => (
- {formatFreq(band.cutoff)} + {formatFreq(EQ_BANDS_HZ[i] ?? band.cutoff)} - setBandGain(i, Number(e.target.value))} - className="flex-1" - style={{ - accentColor: "var(--color-primary)", - opacity: enabled ? 1 : 0.35, - cursor: enabled ? "pointer" : "not-allowed", - height: 4, - }} + onChange={(v) => setBandGain(i, v)} /> {band.gain > 0 ? `+${band.gain}` : band.gain}dB @@ -222,6 +313,56 @@ export default function EqualizerSheet({ open, onClose }: Props) { ))}
+ + {/* Crossfade — smooth transitions between tracks (rockbox pcmbuf) */} +
+ +
+
+ { + if (value?.length) setCrossfadeDuration(value[0]); + }} + overrides={SLIDER_OVERRIDES} + /> +
+ + {crossfadeDuration}s + +
+
); diff --git a/apps/web-mobile/src/components/MiniPlayer/index.tsx b/apps/web-mobile/src/components/MiniPlayer/index.tsx index 9c99037c..dbec24c0 100644 --- a/apps/web-mobile/src/components/MiniPlayer/index.tsx +++ b/apps/web-mobile/src/components/MiniPlayer/index.tsx @@ -10,7 +10,7 @@ import { IconX, IconAdjustmentsHorizontal, } from "@tabler/icons-react"; -import { useAtom, useSetAtom } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { nowPlayingAtom } from "../../atoms/nowpaying"; @@ -22,39 +22,19 @@ import useLike from "../../hooks/useLike"; import useSpotify from "../../hooks/useSpotify"; import { useQueuePersistence } from "../../hooks/useQueuePersistence"; import { useUploadScrobble } from "../../hooks/useUploadScrobble"; -import { getStreamUrl, ensureStreamToken } from "../../api/uploads"; -import { useRockboxDSP } from "../../hooks/useRockboxDSP"; +import { useRockboxEngine } from "../../hooks/useRockboxEngine"; +import { + ensureRockboxReady, + getRockboxPlayer, + registerTracks, + streamUrlFor, +} from "../../lib/audio/rockbox-engine"; +import { ensureStreamToken } from "../../api/uploads"; import EqualizerSheet from "../EqualizerSheet"; import PlayerScreen from "../PlayerScreen"; import axios from "axios"; import { API_URL } from "../../consts"; import _ from "lodash"; -import { consola } from "consola"; - -// Encode decoded PCM samples to a 16-bit WAV blob for HTML5 audio playback. -function pcmToWavBlob(channelData: Float32Array[], sampleRate: number): Blob { - const nc = channelData.length; - const ns = channelData[0].length; - const buf = new ArrayBuffer(44 + ns * nc * 2); - const v = new DataView(buf); - const w = (off: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(off + i, s.charCodeAt(i)); }; - w(0, "RIFF"); v.setUint32(4, 36 + ns * nc * 2, true); - w(8, "WAVE"); w(12, "fmt "); - v.setUint32(16, 16, true); v.setUint16(20, 1, true); - v.setUint16(22, nc, true); v.setUint32(24, sampleRate, true); - v.setUint32(28, sampleRate * nc * 2, true); v.setUint16(32, nc * 2, true); - v.setUint16(34, 16, true); w(36, "data"); - v.setUint32(40, ns * nc * 2, true); - let off = 44; - for (let i = 0; i < ns; i++) { - for (let ch = 0; ch < nc; ch++) { - const s = Math.max(-1, Math.min(1, channelData[ch][i])); - v.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true); - off += 2; - } - } - return new Blob([buf], { type: "audio/wav" }); -} // --------------------------------------------------------------------------- // Source selector bottom sheet @@ -141,11 +121,14 @@ function SourceItem({ label, active, onClick }: { label: string; active: boolean export default function MiniPlayer() { useQueuePersistence(); + // Bridge the in-browser rockbox-wasm engine → jotai atoms + push EQ/crossfade. + useRockboxEngine(); + useUploadScrobble(); const setPlayerScreenOpen = useSetAtom(playerScreenOpenAtom); const [nowPlaying, setNowPlaying] = useAtom(nowPlayingAtom); const [player, setPlayer] = useAtom(playerAtom); - const [queue, setQueue] = useAtom(queueAtom); + const queue = useAtomValue(queueAtom); const [queueIndex, setQueueIndex] = useAtom(queueIndexAtom); const { like, unlike } = useLike(); const { play, pause, next } = useSpotify(); @@ -164,15 +147,6 @@ export default function MiniPlayer() { const [eqSheetOpen, setEqSheetOpen] = useState(false); const [shuffle, setShuffle] = useAtom(shuffleAtom); const [repeatMode, setRepeatMode] = useAtom(repeatModeAtom); - const shuffleRef = useRef(shuffle); - const repeatModeRef = useRef(repeatMode); - - // Hidden audio element for upload player - const audioRef = useRef(null); - - useRockboxDSP(audioRef); - useUploadScrobble(audioRef); - const audioBlobUrlRef = useRef(null); // Keep refs in sync useEffect(() => { @@ -181,9 +155,39 @@ export default function MiniPlayer() { likedRef.current = liked; queueRef.current = queue; queueIndexRef.current = queueIndex; - shuffleRef.current = shuffle; - repeatModeRef.current = repeatMode; - }, [nowPlaying, player, liked, queue, queueIndex, shuffle, repeatMode]); + }, [nowPlaying, player, liked, queue, queueIndex]); + + // Push shuffle/repeat to the engine while the upload (wasm) player is active. + useEffect(() => { + if (player !== "upload") return; + const p = getRockboxPlayer(); + if (p.ready) p.setShuffle(shuffle); + }, [shuffle, player]); + + useEffect(() => { + if (player !== "upload") return; + const p = getRockboxPlayer(); + if (p.ready) p.setRepeat(repeatMode === "one" ? 1 : repeatMode === "all" ? 2 : 0); + }, [repeatMode, player]); + + /** Ensure the wasm engine has the current queue loaded, then return it. + * Used when (re)activating the upload player — e.g. after a restore or when + * switching back to it from Spotify. Idempotent: won't reload if already + * holding a queue. */ + const ensureEngineQueue = useCallback(async () => { + const p = await ensureRockboxReady(); + if (p.queue.length === 0) { + const q = queueRef.current; + if (q.length) { + await ensureStreamToken(); + registerTracks(q); + p.setQueue(q.map(streamUrlFor), false); + const idx = queueIndexRef.current; + if (idx > 0) p.skipTo(idx); + } + } + return p; + }, []); const fetchCurrentlyPlaying = useCallback(async () => { if (playerRef.current === "rockbox" || playerRef.current === "upload") return; @@ -221,123 +225,15 @@ export default function MiniPlayer() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [setNowPlaying, setPlayer]); - // Stop upload audio when switching to another player + // Stop wasm playback when switching away from the upload player. useEffect(() => { if (player === "upload") return; - const audio = audioRef.current; - if (audio) { audio.pause(); audio.src = ""; } - if (audioBlobUrlRef.current) { URL.revokeObjectURL(audioBlobUrlRef.current); audioBlobUrlRef.current = null; } - }, [player]); - - // Load audio when upload track changes - useEffect(() => { - if (player !== "upload") return; - const track = queue[queueIndex]; - if (!track) return; - - if (audioBlobUrlRef.current) { URL.revokeObjectURL(audioBlobUrlRef.current); audioBlobUrlRef.current = null; } - - let cancelled = false; - ensureStreamToken().then(() => { - if (cancelled) return; - const audio = audioRef.current; - if (!audio) return; - audio.src = getStreamUrl(track.uploadId); - if (nowPlayingRef.current?.isPlaying) audio.play().catch(() => {}); - }); - - return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [player, queueIndex]); - - // Audio event listeners for upload player - useEffect(() => { - if (player !== "upload") return; - const audio = audioRef.current; - if (!audio) return; - - const onTimeUpdate = () => { - setNowPlaying((prev) => - prev ? { ...prev, progress: Math.floor(audio.currentTime * 1000) } : prev, - ); - }; - const onPlay = () => setNowPlaying((prev) => prev ? { ...prev, isPlaying: true } : prev); - const onPause = () => setNowPlaying((prev) => prev ? { ...prev, isPlaying: false } : prev); - const onEnded = () => { - const q = queueRef.current; - const cur = queueIndexRef.current; - if (repeatModeRef.current === "one") { - audio.currentTime = 0; - audio.play().catch(() => {}); - setNowPlaying((prev) => prev ? { ...prev, progress: 0, isPlaying: true } : prev); - return; - } - let nextIdx: number; - if (shuffleRef.current && q.length > 1) { - do { nextIdx = Math.floor(Math.random() * q.length); } while (nextIdx === cur); - } else { - nextIdx = cur + 1; - } - const nextTrack = q[nextIdx] ?? (repeatModeRef.current === "all" ? q[0] : null); - const resolvedIdx = q[nextIdx] ? nextIdx : (repeatModeRef.current === "all" ? 0 : -1); - if (nextTrack && resolvedIdx >= 0) { - setQueueIndex(resolvedIdx); - setNowPlaying({ - title: nextTrack.title, - artist: nextTrack.artist, - artistUri: "", - songUri: nextTrack.songUri ?? "", - albumUri: "", - duration: nextTrack.duration, - progress: 0, - albumArt: nextTrack.albumArt ?? undefined, - isPlaying: true, - sha256: nextTrack.sha256, - liked: false, - }); - } - }; - // Fallback: when HTML5 can't decode the format, use audio-type to identify it - // and audio-decode to transcode to WAV for playback. - const onError = async () => { - const errCode = audio.error?.code; - if (errCode !== MediaError.MEDIA_ERR_DECODE && errCode !== MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) return; - if (!audio.src || audio.src.startsWith("blob:")) return; - const srcUrl = audio.src; - try { - const response = await fetch(srcUrl); - const arrayBuffer = await response.arrayBuffer(); - const { default: audioType } = await import("audio-type"); - const format = audioType(new Uint8Array(arrayBuffer)); - consola.info("[upload] HTML5 failed for format:", format, "— falling back to audio-decode"); - const { default: decode } = await import("audio-decode"); - const { channelData, sampleRate } = await decode(arrayBuffer); - const blob = pcmToWavBlob(channelData, sampleRate); - if (audioBlobUrlRef.current) URL.revokeObjectURL(audioBlobUrlRef.current); - audioBlobUrlRef.current = URL.createObjectURL(blob); - audio.src = audioBlobUrlRef.current; - if (nowPlayingRef.current?.isPlaying) audio.play().catch(() => {}); - } catch (e) { - consola.warn("[upload] audio-decode fallback failed:", e); - } - }; - - audio.addEventListener("timeupdate", onTimeUpdate); - audio.addEventListener("play", onPlay); - audio.addEventListener("pause", onPause); - audio.addEventListener("ended", onEnded); - audio.addEventListener("error", onError); - return () => { - audio.removeEventListener("timeupdate", onTimeUpdate); - audio.removeEventListener("play", onPlay); - audio.removeEventListener("pause", onPause); - audio.removeEventListener("ended", onEnded); - audio.removeEventListener("error", onError); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps + const p = getRockboxPlayer(); + if (p.ready) p.pause(); }, [player]); - // Progress ticker (Spotify / Rockbox device) + // Progress ticker (Spotify only — upload progress comes from engine events, + // the WebSocket rockbox device reports its own elapsed). useEffect(() => { progressInterval.current = window.setInterval(() => { setNowPlaying((prev) => { @@ -363,7 +259,7 @@ export default function MiniPlayer() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // WebSocket for Rockbox device + // WebSocket for Rockbox device (companion hardware — monitors its now-playing) useEffect(() => { if (!localStorage.getItem("token")) return; const wsUrl = API_URL.replace("https", "wss").replace("http", "ws"); @@ -442,19 +338,8 @@ export default function MiniPlayer() { const onPlayPause = async () => { if (!nowPlaying) return; if (player === "upload") { - const audio = audioRef.current; - if (!audio) return; - if (nowPlaying.isPlaying) { - audio.pause(); - } else if (!audio.src) { - await ensureStreamToken(); - const track = queueRef.current[queueIndexRef.current]; - if (!track) return; - audio.src = getStreamUrl(track.uploadId); - audio.play().catch(() => {}); - } else { - audio.play().catch(() => {}); - } + const p = await ensureEngineQueue(); + p.toggle(); return; } if (player === "rockbox" && socketRef.current) { @@ -470,31 +355,7 @@ export default function MiniPlayer() { const onNext = () => { if (player === "upload") { - const q = queueRef.current; - const cur = queueIndexRef.current; - let nextIdx: number; - if (shuffleRef.current && q.length > 1) { - do { nextIdx = Math.floor(Math.random() * q.length); } while (nextIdx === cur); - } else { - nextIdx = cur + 1; - } - const nextTrack = q[nextIdx] ?? (repeatModeRef.current === "all" ? q[0] : null); - const resolvedIdx = q[nextIdx] ? nextIdx : (repeatModeRef.current === "all" ? 0 : -1); - if (!nextTrack || resolvedIdx < 0) return; - setQueueIndex(resolvedIdx); - setNowPlaying({ - title: nextTrack.title, - artist: nextTrack.artist, - artistUri: "", - songUri: nextTrack.songUri ?? "", - albumUri: "", - duration: nextTrack.duration, - progress: 0, - albumArt: nextTrack.albumArt ?? undefined, - isPlaying: true, - sha256: nextTrack.sha256, - liked: false, - }); + getRockboxPlayer().next(); return; } if (player === "rockbox" && socketRef.current) { @@ -508,56 +369,23 @@ export default function MiniPlayer() { const onPrevious = () => { if (player !== "upload") return; - const audio = audioRef.current; - if (audio && audio.currentTime > 3) { - audio.currentTime = 0; - setNowPlaying((prev) => prev ? { ...prev, progress: 0 } : prev); - return; - } - const prevIdx = queueIndexRef.current - 1; - const prevTrack = queueRef.current[prevIdx]; - if (!prevTrack) return; - setQueueIndex(prevIdx); - setNowPlaying({ - title: prevTrack.title, - artist: prevTrack.artist, - artistUri: "", - songUri: prevTrack.songUri ?? "", - albumUri: "", - duration: prevTrack.duration, - progress: 0, - albumArt: prevTrack.albumArt ?? undefined, - isPlaying: true, - sha256: prevTrack.sha256, - liked: false, - }); + getRockboxPlayer().prev(); }; const onSeek = useCallback((positionMs: number) => { - const audio = audioRef.current; - if (audio) audio.currentTime = positionMs / 1000; + if (playerRef.current === "upload") { + const p = getRockboxPlayer(); + if (p.ready) p.seek(positionMs); + } setNowPlaying((prev) => prev ? { ...prev, progress: positionMs } : prev); }, [setNowPlaying]); const onSelectQueueIndex = useCallback((idx: number) => { - const q = queueRef.current; - const curIdx = queueIndexRef.current; - const track = q[idx]; + const track = queueRef.current[idx]; if (!track) return; - if (idx < curIdx) { - // History click: splice track out and insert it just before the current - // track so "up next" remains the original current + everything after. - const newQueue = [ - ...q.slice(0, idx), - ...q.slice(idx + 1, curIdx), - track, - ...q.slice(curIdx), - ]; - setQueue(newQueue); - setQueueIndex(curIdx - 1); - } else { - setQueueIndex(idx); - } + getRockboxPlayer().skipTo(idx); + // Optimistic now-playing for instant UI; engine track event reconciles. + setQueueIndex(idx); setNowPlaying({ title: track.title, artist: track.artist, @@ -571,36 +399,11 @@ export default function MiniPlayer() { sha256: track.sha256, liked: false, }); - }, [setQueue, setQueueIndex, setNowPlaying]); + }, [setQueueIndex, setNowPlaying]); const onRemoveFromQueue = useCallback((idx: number) => { - setQueue((prev) => { - const next = prev.filter((_, i) => i !== idx); - const currentIdx = queueIndexRef.current; - if (idx < currentIdx) { - setQueueIndex(currentIdx - 1); - } else if (idx === currentIdx) { - const nextTrack = next[currentIdx] ?? next[currentIdx - 1]; - if (nextTrack) { - setNowPlaying({ - title: nextTrack.title, - artist: nextTrack.artist, - artistUri: "", - songUri: nextTrack.songUri ?? "", - albumUri: "", - duration: nextTrack.duration, - progress: 0, - albumArt: nextTrack.albumArt ?? undefined, - isPlaying: true, - sha256: nextTrack.sha256, - liked: false, - }); - setQueueIndex(Math.min(currentIdx, next.length - 1)); - } - } - return next; - }); - }, [setQueue, setQueueIndex, setNowPlaying]); + getRockboxPlayer().removeAt(idx); + }, []); // Media Session API — keeps Android/iOS lock-screen / notification in sync useEffect(() => { @@ -613,17 +416,19 @@ export default function MiniPlayer() { : [], }); navigator.mediaSession.setActionHandler("play", () => { - audioRef.current?.play().catch(() => {}); + const p = getRockboxPlayer(); + if (p.ready) p.play(); }); navigator.mediaSession.setActionHandler("pause", () => { - audioRef.current?.pause(); + const p = getRockboxPlayer(); + if (p.ready) p.pause(); }); navigator.mediaSession.setActionHandler("previoustrack", onPrevious); navigator.mediaSession.setActionHandler("nexttrack", onNext); // eslint-disable-next-line react-hooks/exhaustive-deps }, [nowPlaying?.title, nowPlaying?.artist, nowPlaying?.albumArt]); - if (!nowPlaying) return