Something went wrong. Try again.
A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571import AsyncStorage from "@react-native-async-storage/async-storage";import { type EnqueueCommand, RemotePlayer, type RemoteQueueItem,} from "@rocksky/sdk/remote";import Constants from "expo-constants";import { atom, getDefaultStore } from "jotai";import { Alert, AppState, Platform } from "react-native";import { type EngineAck, type EngineCommand, type EngineError, type EngineStatus, isEngineAvailable, mediaRenderer, type RendererTrack, localMusicNative, prepareAudioUrl, engineCommand as nativeEngineCommand,} from "../../modules/rocksky-engine";import { getSongLikeState, like, unlike } from "../api/likes";import { fetchStarredSongIds, type NavidromeCredentials, navidromeStreamUrl, resolveNavidromeApiKey, starNavidromeSong, unstarNavidromeSong,} from "../api/navidrome";import { getProfileByDid } from "../api/profile";import { remoteLibraries } from "../api/remoteLibraries";import { ensureStreamToken, getCastStreamUrl, getStreamUrl, submitScrobble,} from "../api/uploads";import { selectedSourceAtom } from "../atoms/devices";import { localEngineActiveAtom, nowPlayingAtom, playbackLockedUntilAtom, playerAtom, progressAtom,} from "../atoms/nowplaying";import { profileAtom } from "../atoms/profile";import { storage } from "../storage";import { enrichRemoteTrack } from "./remoteTrackMetadata";import { type CastPathResolver, castPlayback } from "./castPlayback";import { type DeviceTrack, deviceQueueTrack } from "./deviceMusicModel";import { createLocalPlaybackModes, type PlaybackModes,} from "./localPlaybackModes";import { mapConcurrent } from "./mapConcurrent";import { queryClient } from "./queryClient";import { createQueueSnapshotWriter } from "./queueSnapshotWriter";import { remoteBridge, type TransportAction } from "./remoteBridge";
// Local playback of uploads through the native Rust engine: owns the queue// metadata (the engine only knows URLs), mirrors engine state into the shared// now-playing atoms, routes transport actions, and scrobbles.
export type UploadQueueTrack = { plyrTrackId?: string; audioUrl?: string; renderer?: boolean; remoteLibraryId?: string; remoteTrackId?: string; localId?: string; scrobbleEligible?: boolean; uploadId: string; title: string; artist: string; albumArtist: string; album: string; albumArt: string | null; durationMs: number; songUri: string | null; albumUri: string | null; artistUri: string | null; sha256: string; /** * MusicBrainz id, when the source gives one. A navidrome song carries no * AT-URI, so this is the only handle on its Rocksky record — which is what * the love state and the like button are keyed on. */ mbId?: string; liked?: boolean; /** Subsonic song id, when the track came from navidrome. Its like state is * star/unstar on that id, since there is no AT-URI to address. */ navidromeId?: string; /** * A ready-to-play URL, for sources that don't go through /uploads/:id/stream * — navidrome's ids are not upload ids. Left unset, the URL is built from * `uploadId` and a short-lived stream token. */ streamUrl?: string; mimeType?: string;};
// Both transports feed the same queue, controls, persistence and scrobble gate.function engineCommand(command: { cmd: "status" }): EngineStatus | EngineError;function engineCommand( command: Exclude<EngineCommand, { cmd: "status" }>,): EngineAck | EngineError;function engineCommand( command: EngineCommand,): EngineStatus | EngineAck | EngineError { if (castPlayback.connected() && !rendererActive) return castPlayback.command(command); return command.cmd === "status" ? nativeEngineCommand(command) : nativeEngineCommand(command);}
/** The native engine and Cast both expose normalized volume (0–1). */export function getLocalVolume(): number | null { const status = engineCommand({ cmd: "status" }); return status.ok && Number.isFinite(status.volume) ? status.volume : null;}export function setLocalVolume(volume: number) { if (!Number.isFinite(volume)) return; const result = engineCommand({ cmd: "setVolume", volume: Math.max(0, Math.min(1, volume)), }); if (!result.ok) throw new Error(result.error);}
async function openPlayback( tracks: UploadQueueTrack[], paths: string[] | CastPathResolver, index: number, positionMs = 0, autoplay = true,): Promise<EngineAck | EngineError> { rendererActive = false; rendererKey = null; if (castPlayback.connected()) { nativeEngineCommand({ cmd: "rendererRelease" }); await castPlayback.load(tracks, paths, index, positionMs, autoplay); nativeEngineCommand({ cmd: "pause" }); return { ok: true }; } if (!Array.isArray(paths)) return { ok: false, error: "Chromecast disconnected while preparing music.", }; return nativeEngineCommand({ cmd: "open", paths, startIndex: index });}
/** Mounted once by NowPlayingProvider. Cast session events never broadcast remote commands. */export function startCastPlayback() { return castPlayback.start( async (resumed) => { rendererActive = false; rendererKey = null; nativeEngineCommand({ cmd: "rendererRelease" }); if (queue.length === 0) await restoreLocalQueue(); const index = Math.min( castPlayback.status().index ?? lastIndex ?? resumeIndex, Math.max(0, queue.length - 1), ); const position = resumed ? castPlayback.status().positionMs : store.get(progressAtom); const playing = resumed ? castPlayback.status().state === "playing" : (store.get(nowPlayingAtom)?.isPlaying ?? false); store.set(selectedSourceAtom, { kind: "cast" }); nativeEngineCommand({ cmd: "pause" }); if (queue.length > 0) { if (!resumed || !castPlayback.hasLoadedQueue()) await openPlayback( queue, await playbackPaths(queue), index, position, playing, ); resumePending = false; if (lastIndex !== index) lastTrack = null; lastIndex = index; openedAt = Date.now(); sawPlaying = false; activate(); pollOnce(); } else { store.set(nowPlayingAtom, null); store.set(playerAtom, null); store.set(localEngineActiveAtom, true); } }, () => { if (rendererActive) { resumePending = false; store.set(selectedSourceAtom, { kind: "local" }); activate(); pollOnce(); return; } const previous = castPlayback.status(); resumeIndex = previous.index ?? lastIndex ?? 0; resumePositionMs = previous.positionMs; resumePending = queue.length > 0; resumeWantsPlay = false; if (store.get(selectedSourceAtom)?.kind === "cast") { store.set(selectedSourceAtom, { kind: "local" }); store.set(playerAtom, queue.length ? "local" : null); store.set(nowPlayingAtom, (value) => value ? { ...value, isPlaying: false } : null, ); // A disconnect leaves the phone paused; never start a second audio source automatically. if (queue.length) activate(); else store.set(localEngineActiveAtom, false); } else { deactivate(); } notifyQueue(); void saveQueueSnapshot(); }, );}
const MIN_TRACK_MS = 30_000;const MAX_SCROBBLE_THRESHOLD_MS = 4 * 60_000;
const store = getDefaultStore();
export const localQueueRevisionAtom = atom(0);const notifyQueue = () => store.set(localQueueRevisionAtom, (value) => value + 1);
/** A local edit immediately changes scrobble eligibility, including queued copies. */export function refreshDeviceQueueTracks(tracks: DeviceTrack[]) { const updates = new Map(tracks.map((track) => [track.id, track])); for (const track of queue) { const update = track.localId ? updates.get(track.localId) : undefined; if (update) Object.assign(track, deviceQueueTrack(update)); } notifyQueue(); scheduleQueueSave(); if (lastTrack?.localId && engineOwnsDisplay()) { store.set(nowPlayingAtom, (previous) => previous ? { ...previous, title: lastTrack!.title, artist: lastTrack!.artist, album: lastTrack!.album, cover: lastTrack!.albumArt ?? "", liked: lastTrack!.liked ?? false, } : previous, ); void resolveLikeState(lastTrack, lastIndex ?? 0); }}let rendererActive = false;let rendererKey: string | null = null;const rendererArtworkCache = new Map<string, string | null>();const rendererArtworkPending = new Set<string>();
function extractRendererArtwork(track: RendererTrack) { if (track.albumArt) return; const key = `${track.generation}:${track.uri}`; if (rendererArtworkCache.has(key) || rendererArtworkPending.has(key)) return; rendererArtworkPending.add(key); void mediaRenderer.extractArtwork(track.uri).then((albumArt) => { rendererArtworkCache.set(key, albumArt); if (!albumArt) return; const current = queue.find( (item) => item.renderer && item.uploadId === `renderer:${track.generation}`, ); if (!current) return; current.albumArt = albumArt; notifyQueue(); if (lastTrack === current && engineOwnsDisplay()) { store.set(nowPlayingAtom, (previous) => previous ? { ...previous, cover: albumArt } : previous, ); advertiseNowPlaying(true); } }).catch(() => { rendererArtworkCache.set(key, null); }).finally(() => { rendererArtworkPending.delete(key); });}
function adoptRendererTrack(track: RendererTrack) { const key = `${track.generation}:${track.uri}`; if (rendererActive && rendererKey === key) { if (!pollTimer) activate(); return; } // An incoming stream plays on this phone; pause an existing Cast stream so // accepting it does not leave two outputs playing at once. if (castPlayback.connected()) { castPlayback.command({ cmd: "pause" }); void castPlayback.disconnect().catch(() => {}); } rendererActive = true; rendererKey = key; queue = [{ renderer: true, uploadId: `renderer:${track.generation}`, streamUrl: track.uri, title: track.title, artist: track.artist, albumArtist: track.artist, album: track.album, albumArt: track.albumArt, durationMs: track.durationMs, songUri: null, albumUri: null, artistUri: null, sha256: "", scrobbleEligible: !!track.artist.trim() && track.title !== "Network audio", }]; resumePending = false; resumeIndex = 0; resumePositionMs = 0; lastIndex = null; lastTrack = null; pendingSeek = null; openedAt = Date.now(); sawPlaying = false; store.set(selectedSourceAtom, { kind: "local" }); store.set(playbackLockedUntilAtom, 0); notifyQueue(); activate();}
/** Observe the native receiver even when there was no JS-owned player queue. * The foreground service and Rust continue receiving while JS is suspended. */export function startMediaRendererPlayback() { if (Platform.OS !== "android") return () => {}; let disposed = false; let pending = false; const refresh = async () => { if (pending || disposed) return; pending = true; try { const status = await mediaRenderer.request({ action: "status" }); if (disposed) return; if (status.track) { adoptRendererTrack(status.track); extractRendererArtwork(status.track); pollOnce(); } else if (rendererActive) { rendererActive = false; rendererKey = null; if (queue.every((track) => track.renderer)) { queue = []; notifyQueue(); deactivate(); } } } catch { /* Native module absent in development builds. */ } finally { pending = false; } }; void refresh(); const timer = setInterval(() => void refresh(), 1000); const listener = AppState.addEventListener("change", (state) => { if (state === "active") void refresh(); }); return () => { disposed = true; clearInterval(timer); listener.remove(); };}
let lastTrack: UploadQueueTrack | null = null;let pendingSeek: { position: number; until: number } | null = null;let queue: UploadQueueTrack[] = [];let pollTimer: ReturnType<typeof setInterval> | null = null;let lastIndex: number | null = null;let startedAt = Date.now();let scrobbled = false;let openedAt = 0;let sawPlaying = false;// Love state for the track at `lastIndex`, once the server has answered: null// while the lookup is still out, so a tick before it lands doesn't claim the// track is unloved.let likedResolved: boolean | null = null;let resolvedUri: string | null = null;let likeSeq = 0;let nextLikeRefreshAt = 0;// Navidrome credentials, published by useNavidromeCredentials: star/unstar is// the only like path for a navidrome song, and this module is outside React.let navidromeCreds: NavidromeCredentials | null = null;
/** The user's starred navidrome songs, shared with anything else that asks. */const STARRED_IDS_KEY = ["navidrome", "starred-ids"] as const;
export function setEngineNavidromeCredentials(creds: NavidromeCredentials) { const changed = navidromeCreds?.handle !== creds.handle || navidromeCreds?.apiKey !== creds.apiKey; navidromeCreds = creds; if (changed && lastIndex !== null && queue[lastIndex]) { void resolveLikeState(queue[lastIndex], lastIndex); }}
// ─── Queue persistence ───────────────────────────────────────────────────────//// The queue lives in this module, so quitting the app used to lose it. It is// snapshotted to AsyncStorage and restored paused on the next launch; the// engine is only reopened when the user presses play, exactly like the web// client's upload player.
const localModes = createLocalPlaybackModes( () => AsyncStorage.getItem("local-playback-modes"), (raw) => AsyncStorage.setItem("local-playback-modes", raw), (modes, patch) => { if (patch.shuffle !== undefined) engineCommand({ cmd: "setShuffle", enabled: modes.shuffle }); if (patch.repeat !== undefined) engineCommand({ cmd: "setRepeat", mode: modes.repeat }); if (engineOwnsDisplay()) store.set(nowPlayingAtom, (track) => track ? { ...track, ...modes } : track, ); advertiseNowPlaying(true); },);
export function setLocalShuffle(enabled: boolean) { void localModes.set({ shuffle: enabled }).catch(reportQueueSaveError);}
export function setLocalRepeat(mode: PlaybackModes["repeat"]) { void localModes.set({ repeat: mode }).catch(reportQueueSaveError);}
async function applyLocalPlaybackModes() { await localModes.load(); const modes = localModes.get(); engineCommand({ cmd: "setShuffle", enabled: modes.shuffle }); engineCommand({ cmd: "setRepeat", mode: modes.repeat });}
const QUEUE_KEY = "local-queue";const SAVE_INTERVAL_MS = 1500;
type PersistedQueue = { tracks: UploadQueueTrack[]; index: number; positionMs: number;};
// True once a restored queue is loaded but the engine has not been opened for// it yet: polling has to stay quiet, or the empty engine looks like a queue// that just ended and the restored track is wiped.let resumePending = false;let resumeInFlight = false;let resumeWantsPlay = true;let resumeIndex = 0;let resumePositionMs = 0;
const reportQueueSaveError = (error: unknown) => console.warn("Could not persist the local playback queue", error);
const queueWriter = createQueueSnapshotWriter<PersistedQueue | null>( () => queue.length === 0 ? null : { // Never persist credentialed or short-lived stream URLs. tracks: queue.map(({ streamUrl: _streamUrl, ...track }) => ({ ...track, albumArt: track.remoteLibraryId && !track.albumArt?.startsWith("file://") ? null : track.albumArt, })), index: lastIndex ?? resumeIndex, positionMs: resumePositionMs, }, async (snapshot) => { if (snapshot?.tracks.some((track) => track.renderer)) return; if (snapshot) await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(snapshot)); else await AsyncStorage.removeItem(QUEUE_KEY); }, SAVE_INTERVAL_MS,);
function scheduleQueueSave() { queueWriter.schedule(reportQueueSaveError);}
async function saveQueueSnapshot() { try { await queueWriter.flush(); } catch (error) { reportQueueSaveError(error); }}
AppState.addEventListener("change", (state) => { if (state !== "active" && queue.length > 0) void saveQueueSnapshot();});
/** * Bring back the queue the app was last playing, paused. * * Only the metadata and the saved position are restored; the engine stays * closed until play is pressed, so a cold start makes no sound. The atoms are * claimed only when no other source has, so a restored queue never elbows out * a remote device or Spotify. */export async function restoreLocalQueue(): Promise<boolean> { const session = storage.getToken(); if (!isEngineAvailable() || queue.length > 0) return false; await localModes.load().catch(reportQueueSaveError); let snapshot: PersistedQueue | null = null; try { const raw = await AsyncStorage.getItem(QUEUE_KEY); snapshot = raw ? (JSON.parse(raw) as PersistedQueue) : null; } catch { return false; } if ( storage.getToken() !== session || !snapshot?.tracks?.length || (!session && snapshot.tracks.some( (track) => !track.localId && !track.remoteLibraryId, )) || queue.length > 0 ) return false;
const nativeStatus = engineCommand({ cmd: "status" }); if (nativeStatus.ok && nativeStatus.rendererTrack) { adoptRendererTrack(nativeStatus.rendererTrack); return true; } const nativeStillOpen = nativeStatus.ok && nativeStatus.index !== null && nativeStatus.queueLen === snapshot.tracks.length; const index = Math.min( Math.max( 0, nativeStillOpen ? (nativeStatus.index ?? snapshot.index) : snapshot.index, ), snapshot.tracks.length - 1, ); const track = snapshot.tracks[index]; if (!track) return false;
queue = snapshot.tracks; resumeIndex = index; resumePositionMs = Math.max( 0, nativeStillOpen ? nativeStatus.positionMs : snapshot.positionMs || 0, ); resumePending = !nativeStillOpen; lastIndex = index; lastTrack = track; notifyQueue(); likedResolved = null; resolvedUri = null;
// Only show the restored track if nothing else has claimed the display, and // only then take the transport: a queue waiting to be resumed must not // silence a device or Spotify that is already playing. if (store.get(playerAtom) === null) { store.set(nowPlayingAtom, { title: track.title, artist: track.artist, cover: track.albumArt ?? "", duration: track.durationMs, progress: resumePositionMs, isPlaying: nativeStillOpen && nativeStatus.state === "playing", liked: track.liked ?? false, uri: track.songUri ?? "", album: track.album, artistUri: track.artistUri ?? undefined, albumUri: track.albumUri ?? undefined, ...localModes.get(), }); store.set(playerAtom, "local"); store.set(progressAtom, resumePositionMs); resolveLikeState(track, index); activate(); } else { // Still answer the transport, so picking This Device and pressing play // reopens the engine where it left off. remoteBridge.setLocalHandler(handleTransport); } advertiseQueue(); advertiseStatus(nativeStillOpen ? nativeStatus.state : "paused"); return true;}
/** Open the engine for a restored queue and pick up where it left off. */async function resumeRestoredQueue(): Promise<boolean> { if (!resumePending || queue.length === 0 || resumeInFlight) return false; resumeInFlight = true; const restoringQueue = queue; try { const paths = await playbackPaths(restoringQueue); // A stop or another queue replacement invalidates this delayed open. if (!resumePending || queue !== restoringQueue) return false; await applyLocalPlaybackModes(); if (!resumePending || queue !== restoringQueue) return false; const result = await openPlayback( restoringQueue, paths, resumeIndex, resumePositionMs, resumeWantsPlay, ); if (!result.ok) throw new Error(result.error); resumePending = false; lastIndex = resumeIndex; lastTrack = null; startedAt = Date.now(); scrobbled = false; openedAt = Date.now(); sawPlaying = false; if (resumePositionMs > 0) engineCommand({ cmd: "seek", positionMs: resumePositionMs }); if (!resumeWantsPlay) engineCommand({ cmd: "pause" }); activate(); pollOnce(); return true; } catch (error) { store.set(playbackLockedUntilAtom, 0); store.set(nowPlayingAtom, (track) => track ? { ...track, isPlaying: false } : track, ); Alert.alert( "Could not resume playback", error instanceof Error ? error.message : "Please try again.", ); return false; } finally { resumeInFlight = false; if ( resumePending && queue !== restoringQueue && queue.length > 0 && resumeWantsPlay ) { void resumeRestoredQueue(); } }}
export function isLocalEngineAvailable(): boolean { return isEngineAvailable();}
export function localQueue(): UploadQueueTrack[] { return queue;}
/** Index of the track the engine is on, as of the last poll. */export function localQueueIndex(): number | null { return lastIndex;}
/** Drop a non-current track without interrupting playback. */export function removeLocalAt(index: number) { if (!Number.isInteger(index) || index < 0 || index >= queue.length) return; if (index === (lastIndex ?? resumeIndex)) return; if (castPlayback.connected() && !rendererActive) { const current = castPlayback.status(); const next = queue.filter((_, i) => i !== index); const nextIndex = (current.index ?? 0) - (index < (current.index ?? 0) ? 1 : 0); void (async () => { await castPlayback.load( next, await playbackPaths(next), nextIndex, current.positionMs, current.state === "playing", ); queue = next; lastIndex = nextIndex; notifyQueue(); await saveQueueSnapshot(); })().catch((error) => Alert.alert("Chromecast", String(error))); return; } if (!resumePending) { // The engine may have advanced since the last UI poll. const status = engineCommand({ cmd: "status" }); if ( !status.ok || status.queueLen !== queue.length || status.index === index ) return; const result = engineCommand({ cmd: "remove", index }); if (!result.ok) return; } queue = queue.filter((_, i) => i !== index); if (lastIndex !== null && index < lastIndex) lastIndex -= 1; if (resumePending) resumeIndex = lastIndex ?? 0; notifyQueue(); advertiseQueue(); void saveQueueSnapshot(); setTimeout(pollOnce, 150);}
function handleTransport(action: TransportAction, positionMs?: number) { // A restored queue has no engine behind it yet: the first transport command // opens it at the saved track and position instead of talking to an empty // engine. if (resumePending) { if (action === "pause") { resumeWantsPlay = false; // The native player can survive a JS reload; pause must never be ignored. engineCommand({ cmd: "pause" }); return; } resumeWantsPlay = true; if (action === "seek") resumePositionMs = Math.max(0, positionMs ?? 0); void resumeRestoredQueue(); return; } switch (action) { case "play": engineCommand({ cmd: "play" }); break; case "pause": engineCommand({ cmd: "pause" }); break; case "next": engineCommand({ cmd: "next" }); break; case "previous": engineCommand({ cmd: "previous" }); break; case "seek": pendingSeek = { position: Math.max(0, Math.round(positionMs ?? 0)), until: Date.now() + 2000, }; engineCommand({ cmd: "seek", positionMs: Math.max(0, Math.round(positionMs ?? 0)), }); break; } setTimeout(pollOnce, 150);}
function maybeScrobble(track: UploadQueueTrack, status: EngineStatus) { if (scrobbled || status.state !== "playing") return; if ( !storage.getToken() || ((track.localId || track.renderer) && track.scrobbleEligible !== true) ) return; const duration = track.durationMs || status.durationMs; if (duration < MIN_TRACK_MS) return; const threshold = Math.min(duration * 0.5, MAX_SCROBBLE_THRESHOLD_MS); if (status.positionMs < threshold) return; scrobbled = true; submitScrobble({ title: track.title, artist: track.artist, albumArtist: track.albumArtist || track.artist, album: track.album, albumArt: !track.remoteLibraryId && !track.renderer && (track.albumArt?.startsWith("https://") || track.albumArt?.startsWith("data:image/")) ? track.albumArt : undefined, duration, timestamp: Math.floor(startedAt / 1000), }).catch(() => { scrobbled = false; });}
/** * Ask the server whether the track that just started is already loved. * * The engine knows nothing about loves, so without this the heart was always * drawn empty on this device, whatever the user had loved elsewhere. The * answer also carries the song's AT-URI, which is what makes the like button * work for a navidrome track — the Subsonic payload has no URI of its own. */async function resolveLikeState(track: UploadQueueTrack, index: number) { const seq = ++likeSeq; nextLikeRefreshAt = Date.now() + 60_000; let liked: boolean | null = null; let uri: string | null = null;
if (track.localId) { try { const library = await localMusicNative.library(); liked = library.tracks.find((item: { id: string }) => item.id === track.localId) ?.favorite ?? false; } catch { liked = track.liked ?? false; } }
if (track.navidromeId && navidromeCreds) { // getStarred2 is the love list for navidrome ids; getSong can't be asked, // since a Subsonic song exposes no URI to look it up by. Read through the // query cache: this runs on every track change, and the whole starred list // does not need refetching for each one. try { const starred = await queryClient.fetchQuery({ queryKey: [...STARRED_IDS_KEY, navidromeCreds.handle], queryFn: () => fetchStarredSongIds(navidromeCreds as NavidromeCredentials), staleTime: 60 * 1000, }); liked = starred.has(track.navidromeId); } catch {} } const params = track.songUri ? { uri: track.songUri } : track.mbId ? { mbid: track.mbId } : null; if (!track.localId && liked !== true && params) { const state = await queryClient .fetchQuery({ queryKey: ["song", "like-state", storage.getDid(), params], queryFn: () => getSongLikeState(params), staleTime: 5 * 60 * 1000, }) .catch(() => null); if (state) { liked = state.liked; uri = state.uri; } }
if (liked === null) nextLikeRefreshAt = Date.now() + 5000;
// A later track (or a later lookup for this one) has taken over since. if ( liked === null || seq !== likeSeq || queue[index] !== track || lastIndex !== index ) return; likedResolved = liked; resolvedUri = uri; track.liked = liked; const resolved = liked; if (!engineOwnsDisplay()) return; store.set(nowPlayingAtom, (prev) => prev ? { ...prev, liked: resolved, uri: prev.uri || uri || "" } : prev, ); advertiseNowPlaying(true);}
/** Refresh after a like from another surface, such as a story. */export function refreshLocalLikeState() { if (lastIndex !== null && queue[lastIndex]) { void resolveLikeState(queue[lastIndex], lastIndex); }}
/** * Love / un-love whatever is playing locally. * * The transport's own toggle needs an AT-URI, which a navidrome song has * none of — tapping the heart simply did nothing for those. Star/unstar takes * the Subsonic id and has the server publish the like record, so route by * whichever handle the track actually carries. */export async function toggleLocalLike(): Promise<boolean> { const index = lastIndex; if (index === null) return false; const track = queue[index]; if (!track) return false;
++likeSeq; // A lookup started before this tap must not undo it. const current = store.get(nowPlayingAtom)?.liked ?? false; const next = !current; const apply = (value: boolean) => { track.liked = value; if (queue[index] !== track || lastIndex !== index) return; likedResolved = value; if (!engineOwnsDisplay()) return; store.set(nowPlayingAtom, (prev) => prev ? { ...prev, liked: value } : prev, ); advertiseNowPlaying(true); }; apply(next);
const uri = track.songUri ?? resolvedUri; try { if (track.localId) { await localMusicNative.mutate({ action: "favorite", id: track.localId, favorite: next, }); queryClient.invalidateQueries({ queryKey: ["device-library"] }); } else if (track.navidromeId && navidromeCreds) { if (next) await starNavidromeSong(navidromeCreds, track.navidromeId); else await unstarNavidromeSong(navidromeCreds, track.navidromeId); } else if (uri) { if (next) await like(uri); else await unlike(uri); } else { apply(current); return false; } } catch { apply(current); return false; } // The cached love state is now out of date — including the favourites list the // library's own tab reads — so drop it rather than serve a stale heart. queryClient.invalidateQueries({ queryKey: STARRED_IDS_KEY }); queryClient.invalidateQueries({ queryKey: ["song", "like-state"] }); queryClient.invalidateQueries({ queryKey: ["navidrome", "favorites"] }); return true;}
/** True unless the user has switched the display to another source. */function engineOwnsDisplay(): boolean { const selected = store.get(selectedSourceAtom); return !selected || selected.kind === "local" || selected.kind === "cast";}
function pollOnce() { // Nothing is open yet — the restored queue is waiting for a play. if (resumePending) return; const status = engineCommand({ cmd: "status" }); if (!status.ok) return; if (status.rendererTrack) adoptRendererTrack(status.rendererTrack); // Metadata is already updated optimistically; wait for the native queue to catch up. if (status.queueLen !== queue.length) return;
if (status.state === "playing") sawPlaying = true; // Don't mistake the engine's startup lag for the queue having ended. const settled = sawPlaying || Date.now() - openedAt > 10_000; const finished = status.state === "stopped" && settled && (status.index === null || status.queueLen === 0); if (finished) { deactivate(); return; }
const index = status.index; if (index === null) return; const track = queue[index]; if (!track) return;
const trackChanged = track !== lastTrack; if (trackChanged) pendingSeek = null; if (index !== lastIndex || trackChanged) notifyQueue(); lastTrack = track; lastIndex = index; if (trackChanged) { startedAt = Date.now(); scrobbled = false; likedResolved = track.liked ?? null; resolvedUri = null; resolveLikeState(track, index); if (track.remoteLibraryId && track.remoteTrackId) { void enrichRemoteTrack(track).then((metadata) => { if (!metadata) return; Object.assign(track, metadata); if (queue.includes(track)) { notifyQueue(); void saveQueueSnapshot(); } }).catch(() => {}); } } else if (Date.now() >= nextLikeRefreshAt) { void resolveLikeState(track, index); }
const durationMs = track.durationMs || status.durationMs; let positionMs = status.positionMs; if (pendingSeek) { if ( Date.now() < pendingSeek.until && Math.abs(positionMs - pendingSeek.position) > 1500 ) positionMs = pendingSeek.position; else pendingSeek = null; } resumePositionMs = positionMs; // The engine keeps playing when the user looks at another device, but it // must not write the display then — that is the other source's to own. if (engineOwnsDisplay()) { store.set(nowPlayingAtom, { title: track.title, artist: track.artist, cover: track.albumArt ?? "", duration: durationMs, sampleRate: status.sampleRate, bitrate: status.bitrate, codec: status.codec, progress: positionMs, isPlaying: Date.now() < store.get(playbackLockedUntilAtom) ? (store.get(nowPlayingAtom)?.isPlaying ?? status.state === "playing") : status.state === "playing", // Love state belongs to this queue track, never the previously displayed source. liked: likedResolved ?? track.liked ?? false, uri: track.songUri ?? resolvedUri ?? "", album: track.album, artistUri: track.artistUri ?? undefined, albumUri: track.albumUri ?? undefined, ...(rendererActive ? { shuffle: status.shuffle, repeat: status.repeat } : localModes.get()), }); store.set(playerAtom, castPlayback.connected() && !rendererActive ? "cast" : "local"); store.set(progressAtom, positionMs); }
advertiseNowPlaying(trackChanged); advertiseStatus(status.state); advertiseQueue(); // Throttle position saves without postponing them on every poll; a track change // flushes it so a quit right after a skip still resumes on the right track. if (trackChanged) void saveQueueSnapshot(); else scheduleQueueSave();
maybeScrobble(track, status);}
function activate() { store.set(localEngineActiveAtom, true); remoteBridge.setLocalHandler(handleTransport); remoteBridge.setRoute("local", null); if (!pollTimer) { pollTimer = setInterval(pollOnce, 500); }}
function deactivate() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } remoteBridge.setLocalHandler(null); store.set(localEngineActiveAtom, false); lastIndex = null; if (["local", "cast"].includes(store.get(playerAtom) ?? "")) { store.set(nowPlayingAtom, null); store.set(playerAtom, null); store.set(progressAtom, 0); }}
/** The URL to hand the engine for a track, whatever its source. */function streamUrlFor(track: UploadQueueTrack): string { if (track.streamUrl) return track.streamUrl; // A restored queue carries no stream URL — it is credentialed, so it isn't // persisted — and is rebuilt from the Subsonic id instead. if (track.navidromeId && navidromeCreds) { return navidromeStreamUrl(track.navidromeId, navidromeCreds); } return getStreamUrl(track.uploadId);}
async function playbackPaths( tracks: UploadQueueTrack[], validate = true,): Promise<string[] | CastPathResolver> { if (!castPlayback.connected()) return resolvePaths(tracks); if (validate) for (const track of tracks) if (track.audioUrl || track.streamUrl?.match(/^https?:/i)) await validateTrackUrl(track, track.audioUrl || track.streamUrl!); const owner = storage.getToken(); let localTracks: Promise<Map<string, DeviceTrack>> | undefined; const resolve: CastPathResolver = async (track, _index, signal) => { if (signal.aborted || storage.getToken() !== owner) throw new Error("Chromecast queue changed."); if (track.localId) { localTracks ??= localMusicNative .library() .then( (library) => new Map<string, DeviceTrack>( library.tracks.map((item: DeviceTrack) => [item.id, item]), ), ); const current = (await localTracks).get(track.localId); if (!current) throw new Error(`Local file is no longer available: ${track.title}`); Object.assign(track, deviceQueueTrack(current)); if (signal.aborted) throw new Error("Chromecast queue changed."); return localMusicNative.path(track.localId); } if (track.remoteLibraryId && track.remoteTrackId) return remoteLibraries.stream(track.remoteLibraryId, track.remoteTrackId); if (track.streamUrl) return track.streamUrl; if (!track.navidromeId) return getCastStreamUrl(track.uploadId, signal); // Resolve Navidrome credentials once on demand; subsequent tracks reuse them. return (await resolvePaths([track]))[0]; }; return resolve;}
async function resolvePaths(tracks: UploadQueueTrack[]): Promise<string[]> { for (const track of tracks) { if (track.audioUrl || track.streamUrl?.match(/^https?:/i)) await validateTrackUrl(track, track.audioUrl || track.streamUrl!); } if (tracks.some((track) => track.localId)) { const library = await localMusicNative.library(); const deviceTracks = new Map<string, DeviceTrack>( library.tracks.map((track: DeviceTrack) => [track.id, track]), ); for (const track of tracks) { if (!track.localId) continue; const current = deviceTracks.get(track.localId); if (!current) throw new Error(`Local file is no longer available: ${track.title}`); Object.assign(track, deviceQueueTrack(current)); } } if ( tracks.some((track) => track.navidromeId && !track.streamUrl) && !navidromeCreds ) { const did = storage.getDid(); if (!did) throw new Error("Sign in to resume your library tracks."); const profile = store.get(profileAtom) ?? (await getProfileByDid(did)); if (!profile?.handle) throw new Error("Could not load your library account. Please try again."); const credentials = await queryClient.fetchQuery<NavidromeCredentials>({ queryKey: ["navidrome", "credentials", profile.handle], staleTime: Number.POSITIVE_INFINITY, queryFn: async () => ({ handle: profile.handle, apiKey: await resolveNavidromeApiKey(), }), }); setEngineNavidromeCredentials(credentials); } // Only the upload-backed path needs the token; a navidrome queue carries its // own credentialed URLs. const needsToken = tracks.some( (t) => !t.localId && !t.remoteLibraryId && !t.streamUrl && !t.navidromeId, ); const casting = castPlayback.connected(); if (needsToken && !casting) await ensureStreamToken(); return mapConcurrent(tracks, async (track) => track.remoteLibraryId && track.remoteTrackId ? await (async () => { const url = await remoteLibraries.stream(track.remoteLibraryId!, track.remoteTrackId!); await validateTrackUrl(track, url); return track.streamUrl!; })() : track.localId ? await localMusicNative.path(track.localId) : casting && !track.navidromeId && !track.streamUrl ? await getCastStreamUrl(track.uploadId) : streamUrlFor(track), );}
/** Replace the queue with `tracks` and start playing at `startIndex`. */export async function playUploads( tracks: UploadQueueTrack[], startIndex: number, current: () => boolean = () => true,): Promise<boolean> { if (!current()) return false; const session = storage.getToken(); if ( !session && tracks.some((track) => !track.localId && !track.remoteLibraryId && !track.audioUrl) ) return false; const casting = castPlayback.connected(); if ((!casting && !isEngineAvailable()) || tracks.length === 0) return false; const paths = await playbackPaths(tracks); if (!current()) return false; if (casting !== castPlayback.connected()) return false; await applyLocalPlaybackModes(); if (!current() || storage.getToken() !== session) return false; const result = await openPlayback(tracks, paths, startIndex); if (!result.ok) return false; queue = tracks; resumePending = false; resumeIndex = startIndex; resumePositionMs = 0; store.set(selectedSourceAtom, { kind: castPlayback.connected() ? "cast" : "local", }); lastIndex = startIndex; lastTrack = null; pendingSeek = null; notifyQueue(); startedAt = Date.now(); scrobbled = false; openedAt = Date.now(); sawPlaying = false; await saveQueueSnapshot(); if (storage.getToken() !== session) return false; activate(); pollOnce(); return true;}
/** * Queue `tracks` right after whatever is playing, or start them if nothing is. * * The engine only knows URLs, so the metadata queue has to be spliced at the * same place the engine inserts — immediately after the current index. */export async function queueUploadsNext( tracks: UploadQueueTrack[],): Promise<boolean> { return enqueueTracksInOrder(tracks, "next");}
/** Queue tracks at the end without replacing a paused restored queue. */export async function queueUploadsLast( tracks: UploadQueueTrack[], current: () => boolean = () => true,): Promise<boolean> { return enqueueTracksInOrder(tracks, "last", current);}
// Context menus in different screens can resolve URLs concurrently. Preserve// their insertion order and never let a later operation overwrite the queue.let pendingEnqueue: Promise<unknown> = Promise.resolve();function enqueueTracksInOrder(tracks: UploadQueueTrack[], where: "next" | "last", current: () => boolean = () => true) { const castSession = castPlayback.connected() ? castPlayback.sessionGeneration() : null; const result = pendingEnqueue.then(() => { if (castSession !== null && (!castPlayback.connected() || castPlayback.sessionGeneration() !== castSession)) { throw new Error("Chromecast disconnected. Reconnect and try again."); } if (!current()) return false; return enqueueLocalTracks(tracks, where, current); }); pendingEnqueue = result.catch(() => {}); return result;}
async function enqueueLocalTracks( tracks: UploadQueueTrack[], where: "next" | "last", current: () => boolean = () => true,): Promise<boolean> { if (!current()) return false; if (tracks.length === 0) return false; if (!castPlayback.connected() && !isEngineAvailable()) return false; if (queue.length === 0 || (castPlayback.connected() && !castPlayback.hasLoadedQueue())) return playUploads(tracks, 0, current); let index = lastIndex ?? resumeIndex; if (castPlayback.connected()) { const session = castPlayback.sessionGeneration(); for (const track of tracks) if (track.audioUrl || track.streamUrl?.match(/^https?:/i)) await validateTrackUrl(track, track.audioUrl || track.streamUrl!); const paths = await playbackPaths(queue, false); if (!castPlayback.connected() || session !== castPlayback.sessionGeneration()) { throw new Error("Chromecast disconnected. Reconnect and try again."); } if (!current()) return false; const castStatus = castPlayback.status(); index = castStatus.index ?? index; const at = where === "next" ? index + 1 : queue.length; const next = [...queue.slice(0, at), ...tracks, ...queue.slice(at)]; await castPlayback.load( next, paths, index, castStatus.positionMs, castStatus.state === "playing", ); queue = next; store.set(selectedSourceAtom, { kind: "cast" }); notifyQueue(); advertiseQueue(); await saveQueueSnapshot(); return true; } if (!resumePending) { const paths = await resolvePaths(tracks); if (!current()) return false; if (castPlayback.connected()) return enqueueLocalTracks(tracks, where, current); // URL resolution can take time: read the current track after it completes. const status = engineCommand({ cmd: "status" }); if (!status.ok || status.queueLen !== queue.length) return false; if (status.index === null) return playUploads(tracks, 0, current); const result = engineCommand({ cmd: where === "next" ? "insertNext" : "append", paths, }); if (!result.ok) return false; index = status.index; lastIndex = index; resumePositionMs = pendingSeek?.position ?? status.positionMs; } const at = where === "next" ? index + 1 : queue.length; queue = [...queue.slice(0, at), ...tracks, ...queue.slice(at)]; notifyQueue(); advertiseQueue(); await saveQueueSnapshot(); return true;}
export function skipToLocal(index: number) { if (index < 0 || index >= queue.length) return; if (resumePending) { resumeWantsPlay = true; resumeIndex = index; resumePositionMs = 0; void resumeRestoredQueue(); return; } engineCommand({ cmd: "skipTo", index }); setTimeout(pollOnce, 150);}
export function stopLocalPlayback() { engineCommand({ cmd: "stop" }); queue = []; lastTrack = null; pendingSeek = null; notifyQueue(); resumePending = false; void saveQueueSnapshot(); deactivate();}
// ─── Broadcast as a controllable remote player ───────────────────────────────//// The phone registers itself on the remote-control WebSocket through the SDK's// RemotePlayer, so it shows up in every other client's device picker and can be// driven from them — the same protocol playerd and the desktop app speak.
let remotePlayer: RemotePlayer | null = null;let lastAdvertisedAt = 0;let advertisedStatus: "playing" | "paused" | "stopped" | null = null;let advertisedQueueKey = "";
const deviceLabel = (): string => Constants.deviceName ?? (Platform.OS === "android" ? "Rocksky Android" : "Rocksky iOS");
const toRemoteQueueItem = (track: UploadQueueTrack): RemoteQueueItem => ({ // A navidrome song is addressed by its Subsonic id, an upload by its id; // sending the right one is what lets a controller re-enqueue it. url: track.audioUrl, uploadId: track.audioUrl || track.localId || track.navidromeId || track.remoteLibraryId || track.renderer ? undefined : track.uploadId, trackId: track.navidromeId, title: track.title, artist: track.artist, album: track.album, albumArtist: track.albumArtist, albumArt: !track.remoteLibraryId && !track.renderer && (track.albumArt?.startsWith("https://") || track.albumArt?.startsWith("data:image/")) ? track.albumArt : undefined, durationMs: track.durationMs, songUri: track.songUri ?? undefined, albumUri: track.albumUri ?? undefined,});
/** Mirror the playing track out to controllers; throttled unless `force`. */function advertiseNowPlaying(force = false) { const player = remotePlayer; if (!player) return; const index = lastIndex; const track = index !== null ? queue[index] : undefined; const np = store.get(nowPlayingAtom); if (!track || !np) return; const now = Date.now(); // The engine polls twice a second; controllers only need a few updates a // second to animate smoothly, so progress goes out every two. if (!force && now - lastAdvertisedAt < 2000) return; lastAdvertisedAt = now; player.setNowPlaying({ title: track.title, artist: track.artist, album: track.album, albumArtist: track.albumArtist, albumArt: !track.remoteLibraryId && !track.renderer && (track.albumArt?.startsWith("https://") || track.albumArt?.startsWith("data:image/")) ? track.albumArt : undefined, durationMs: np.duration, elapsedMs: np.progress, sampleRate: np.sampleRate, bitrate: np.bitrate, codec: np.codec, isPlaying: np.isPlaying, shuffle: np.shuffle, repeat: np.repeat, songUri: track.songUri ?? undefined, albumUri: track.albumUri ?? undefined, artistUri: track.artistUri ?? undefined, sha256: track.sha256 || undefined, liked: np.liked, });}
function advertiseStatus(state: "playing" | "paused" | "stopped") { if (!remotePlayer || advertisedStatus === state) return; advertisedStatus = state; remotePlayer.setStatus(state);}
function advertiseQueue() { if (!remotePlayer) return; const index = lastIndex ?? 0; const key = `${queue.length}:${index}:${queue[0]?.uploadId ?? ""}`; if (key === advertisedQueueKey) return; advertisedQueueKey = key; remotePlayer.setQueue(queue.map(toRemoteQueueItem), index);}
function fromRemoteQueueItem(item: RemoteQueueItem): UploadQueueTrack | null { const base = { title: item.title, artist: item.artist, albumArtist: item.albumArtist ?? item.artist, album: item.album ?? "", albumArt: item.albumArt ?? null, durationMs: item.durationMs ?? 0, songUri: item.songUri ?? null, albumUri: item.albumUri ?? null, artistUri: null, sha256: "", }; if (item.url) return { ...base, uploadId: "external-audio", audioUrl: item.url, streamUrl: item.url }; if (item.trackId && navidromeCreds) { return { ...base, uploadId: item.trackId, navidromeId: item.trackId, streamUrl: navidromeStreamUrl(item.trackId, navidromeCreds), }; } // Without credentials a navidrome id can't be turned into a stream URL, and // an item with neither id is unplayable: drop it rather than queue silence. if (item.uploadId) return { ...base, uploadId: item.uploadId }; return null;}
async function handleRemoteEnqueue(cmd: EnqueueCommand) { const tracks = cmd.tracks .map(fromRemoteQueueItem) .filter((track): track is UploadQueueTrack => track !== null); if (tracks.length === 0) return; const ordered = cmd.shuffle ? shuffleTracks(tracks) : tracks; if (cmd.mode === "next") { if (!await queueUploadsNext(ordered)) throw new Error("The target player could not add this audio to its queue."); return; } if (cmd.mode === "last") { if (!await queueUploadsLast(ordered)) throw new Error("The target player could not add this audio to its queue."); return; } const startIndex = cmd.shuffle ? 0 : Math.min(Math.max(0, cmd.startIndex), ordered.length - 1); if (!await playUploads(ordered, startIndex)) throw new Error("The target player could not start this audio.");}
function shuffleTracks(tracks: UploadQueueTrack[]): UploadQueueTrack[] { const out = [...tracks]; for (let i = out.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [out[i], out[j]] = [out[j], out[i]]; } return out;}
/** * Register this device on the remote-control socket. * * Connected for as long as the user is signed in, not only while playing, so * another client can enqueue to an idle phone and have it start. * * queueMove is not registered because the engine has no move command. */export function startLocalRemotePlayer() { if (remotePlayer || !isEngineAvailable() || !storage.getToken()) return; const player = new RemotePlayer({ token: () => storage.getToken() ?? undefined, name: deviceLabel(), }); player .on("play", () => handleTransport("play")) .on("pause", () => handleTransport("pause")) .on("next", () => handleTransport("next")) .on("previous", () => handleTransport("previous")) .on("seek", (positionMs) => handleTransport("seek", positionMs)) .on("enqueue", (cmd) => { void handleRemoteEnqueue(cmd).catch((error) => { const message = error instanceof Error ? error.message : "Could not queue audio"; remotePlayer?.commandError(message); Alert.alert("Audio URL rejected", message); }); }) .on("setAudioSettings", (settings) => { engineCommand({ cmd: "setAudioSettings", settings }); }) .on("queueJump", (index) => skipToLocal(index)) .on("queueRemove", (index) => removeLocalAt(index)) .on("setShuffle", setLocalShuffle) .on("setRepeat", setLocalRepeat) .on("setVolume", (volume) => { engineCommand({ cmd: "setVolume", volume }); setTimeout(pollOnce, 150); }); player.connect(); remotePlayer = player; advertisedStatus = null; advertisedQueueKey = ""; // Catch a controller up if playback is already under way. advertiseQueue(); advertiseNowPlaying(true);}
export function stopLocalRemotePlayer() { if (!remotePlayer) return; remotePlayer.setStatus("stopped"); remotePlayer.disconnect(); remotePlayer = null; advertisedStatus = null; advertisedQueueKey = "";}
/** * This phone's own device id on the remote socket. * * The picker lists it as "This Device" already, so the registry entry for * ourselves has to be filtered out or the same player appears twice. */export function localRemoteDeviceId(): string | null { return remotePlayer?.id || null;}
async function validateTrackUrl(track: UploadQueueTrack, url: string) { const metadata = await audioUrlTrack(url); const known = track.uploadId !== "external-audio" && !track.uploadId.startsWith("url:"); Object.assign(track, { ...metadata, uploadId: known ? track.uploadId : metadata.uploadId, title: known ? track.title || metadata.title : metadata.title, artist: metadata.artist || track.artist, album: metadata.album || track.album, albumArtist: metadata.albumArtist || track.albumArtist, albumArt: metadata.albumArt || track.albumArt, durationMs: metadata.durationMs || track.durationMs, songUri: track.songUri, albumUri: track.albumUri, artistUri: track.artistUri, sha256: track.sha256, scrobbleEligible: track.scrobbleEligible ?? !!(metadata.artist || track.artist), });}
export async function audioUrlTrack(url: string): Promise<UploadQueueTrack> { const metadata = await prepareAudioUrl(url); return { audioUrl: metadata.url, streamUrl: metadata.url, uploadId: `url:${metadata.cacheId}`, title: metadata.title, artist: metadata.artist ?? "", album: metadata.album ?? "", albumArtist: metadata.albumArtist || metadata.artist || "", albumArt: metadata.albumArt ?? null, durationMs: metadata.durationMs ?? 0, mimeType: metadata.mimeType, scrobbleEligible: !!metadata.artist, songUri: null, albumUri: null, artistUri: null, sha256: "" };}export async function playAudioUrl(url: string, mode: "now" | "next" | "last") { // Resolution is inside the serialized queue operation, before engine mutation. const track: UploadQueueTrack = { audioUrl: url, streamUrl: url, uploadId: "external-audio", title: "Audio stream", artist: "", albumArtist: "", album: "", albumArt: null, durationMs: 0, songUri: null, albumUri: null, artistUri: null, sha256: "" }; const { sendToSelectedPlayer } = await import("./libraryPlayback"); if (await sendToSelectedPlayer([track], mode)) return; const accepted = mode === "next" ? await queueUploadsNext([track]) : mode === "last" ? await queueUploadsLast([track]) : await playUploads([track], 0); if (!accepted) throw new Error("Could not start audio playback. Please try again.");}
/** Resolve only the current track for background waveform analysis, without * revalidating or loading any queue entries into the playback engine. */export async function localWaveformSource(): Promise<string | null> { const track = lastIndex === null ? undefined : queue[lastIndex]; if (!track) return null; if (track.localId) return localMusicNative.path(track.localId); if (track.remoteLibraryId && track.remoteTrackId) return remoteLibraries.stream(track.remoteLibraryId, track.remoteTrackId); return track.audioUrl || streamUrlFor(track);}