From 26fba836f48093c03911387a3f48780b7ac1a864 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:02:10 -0400 Subject: [PATCH] fix now playing source label --- package.json | 2 +- src/app/(tabs)/index.tsx | 16 +++-- src/app/(tabs)/library/album/[key].tsx | 10 +++- src/app/(tabs)/library/artist/[name].tsx | 10 +++- .../library/artist/[name]/appearances.tsx | 5 +- .../(tabs)/library/artist/[name]/songs.tsx | 5 +- src/app/(tabs)/library/index.tsx | 5 +- src/app/(tabs)/library/playlist/[id].tsx | 10 +++- src/app/recently-played.tsx | 5 +- src/app/signal/scan.tsx | 4 +- src/audio/playbackController.ts | 46 +++++++++++---- src/audio/playbackSource.ts | 32 ++++++++++ src/car/carPlayback.ts | 58 +++++++++++++++++-- src/components/library/FoldersView.tsx | 14 ++++- src/components/player/NowPlayingOverlay.tsx | 3 + src/components/search/QuickSearchOverlay.tsx | 7 ++- src/library/folderTree.test.mts | 15 +++++ src/library/folderTree.ts | 2 + .../playbackTargetPresentation.test.mts | 35 +++++++++++ src/playback/playbackTargetPresentation.ts | 6 +- src/session/sessionPersistence.ts | 1 + src/session/sessionState.test.mts | 31 ++++++++++ src/session/sessionState.ts | 8 +++ src/stores/queueStore.ts | 23 ++++++-- src/types/audio.ts | 19 ++++++ 25 files changed, 327 insertions(+), 45 deletions(-) create mode 100644 src/audio/playbackSource.ts create mode 100644 src/playback/playbackTargetPresentation.test.mts diff --git a/package.json b/package.json index d5c6e07..a53fc43 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts", "test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/db/libraryMaintenance.test.mts src/lib/cacheInvalidation.test.mts", "test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts", - "test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts", + "test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts src/playback/playbackTargetPresentation.test.mts", "test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs", "test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts", "test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts", diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index 8970656..f46b0e7 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -604,17 +604,23 @@ export default function HomeScreen() { }); }; - const playTrackList = (list: DbTrack[], index = 0) => { + const playRecentlyPlayed = (list: DbTrack[], index = 0) => { if (list.length === 0) return; - void playTracks(list.map(dbTrackToTrack), index); + void playTracks(list.map(dbTrackToTrack), { + startIndex: index, + source: { kind: 'recently-played', label: 'Recently Played' }, + }); }; const playSpotlight = (shuffled = false) => { if (spotlightTracks.length === 0) return; + const source = spotlightContent?.kind === 'album' + ? { kind: 'album' as const, label: spotlightContent.album.album } + : { kind: 'artist' as const, label: spotlightContent?.artist.artist ?? 'Artist' }; if (shuffled) { - void shuffleTracks(spotlightTracks.map(dbTrackToTrack)); + void shuffleTracks(spotlightTracks.map(dbTrackToTrack), source); } else { - void playTracks(spotlightTracks.map(dbTrackToTrack), 0); + void playTracks(spotlightTracks.map(dbTrackToTrack), { source }); } }; @@ -682,7 +688,7 @@ export default function HomeScreen() { track={track} active={track.path === currentPath} swipeToQueue={false} - onPress={() => playTrackList(recentTracks, index)} + onPress={() => playRecentlyPlayed(recentTracks, index)} onLongPress={() => setActionTrack(track)} onOpenActions={() => setActionTrack(track)} /> diff --git a/src/app/(tabs)/library/album/[key].tsx b/src/app/(tabs)/library/album/[key].tsx index 20a23b4..8afae7f 100644 --- a/src/app/(tabs)/library/album/[key].tsx +++ b/src/app/(tabs)/library/album/[key].tsx @@ -84,7 +84,10 @@ export default function AlbumScreen() { }, [tracks]); const playFrom = (index: number) => { - void playTracks(tracks.map(dbTrackToTrack), index); + void playTracks(tracks.map(dbTrackToTrack), { + startIndex: index, + source: { kind: 'album', label: album?.album ?? tracks[0]?.album ?? 'Album' }, + }); }; // Eligibility can filter an album out of the store list (a single reached via @@ -164,7 +167,10 @@ export default function AlbumScreen() { disabled={tracks.length === 0} onBack={handleBack} onPlay={() => playFrom(0)} - onShuffle={() => void shuffleTracks(tracks.map(dbTrackToTrack))} + onShuffle={() => void shuffleTracks(tracks.map(dbTrackToTrack), { + kind: 'album', + label: album?.album ?? tracks[0]?.album ?? 'Album', + })} scrollY={scrollY} heroFaded={heroFaded} collapsed={collapsed} diff --git a/src/app/(tabs)/library/artist/[name].tsx b/src/app/(tabs)/library/artist/[name].tsx index 4a5c318..87f20b5 100644 --- a/src/app/(tabs)/library/artist/[name].tsx +++ b/src/app/(tabs)/library/artist/[name].tsx @@ -88,13 +88,19 @@ export default function ArtistScreen() { const playTrackListFrom = (tracks: readonly DbTrack[], index: number) => { if (tracks.length === 0) return; - void playTracks(tracks.map(dbTrackToTrack), index); + void playTracks(tracks.map(dbTrackToTrack), { + startIndex: index, + source: { kind: 'artist', label: name }, + }); }; const playArtist = () => playTrackListFrom(detail.playbackTracks, 0); const shuffleArtist = () => { if (detail.playbackTracks.length === 0) return; - void shuffleTracks(detail.playbackTracks.map(dbTrackToTrack)); + void shuffleTracks(detail.playbackTracks.map(dbTrackToTrack), { + kind: 'artist', + label: name, + }); }; const openSection = (target: ArtistSectionTarget) => { diff --git a/src/app/(tabs)/library/artist/[name]/appearances.tsx b/src/app/(tabs)/library/artist/[name]/appearances.tsx index 72a09f6..f86af4a 100644 --- a/src/app/(tabs)/library/artist/[name]/appearances.tsx +++ b/src/app/(tabs)/library/artist/[name]/appearances.tsx @@ -44,7 +44,10 @@ export default function ArtistAppearancesScreen() { const playFrom = (index: number) => { if (tracks.length === 0) return; - void playTracks(tracks.map(dbTrackToTrack), index); + void playTracks(tracks.map(dbTrackToTrack), { + startIndex: index, + source: { kind: 'artist', label: name }, + }); }; return ( diff --git a/src/app/(tabs)/library/artist/[name]/songs.tsx b/src/app/(tabs)/library/artist/[name]/songs.tsx index 1f5d3d4..2b1002d 100644 --- a/src/app/(tabs)/library/artist/[name]/songs.tsx +++ b/src/app/(tabs)/library/artist/[name]/songs.tsx @@ -44,7 +44,10 @@ export default function ArtistSongsScreen() { const playFrom = (index: number) => { if (tracks.length === 0) return; - void playTracks(tracks.map(dbTrackToTrack), index); + void playTracks(tracks.map(dbTrackToTrack), { + startIndex: index, + source: { kind: 'artist', label: name }, + }); }; return ( diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index 6b2e58a..c0e6b09 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -133,7 +133,10 @@ export default function LibraryScreen() { // Tap index is within sortedTracks so the tapped row is the track that plays. const playAllFrom = (index: number) => { - void playTracks(sortedTracks.map(dbTrackToTrack), index); + void playTracks(sortedTracks.map(dbTrackToTrack), { + startIndex: index, + source: { kind: 'library', label: 'Library' }, + }); }; const openSearch = () => openQuickSearch(); diff --git a/src/app/(tabs)/library/playlist/[id].tsx b/src/app/(tabs)/library/playlist/[id].tsx index fd7566d..4d9c430 100644 --- a/src/app/(tabs)/library/playlist/[id].tsx +++ b/src/app/(tabs)/library/playlist/[id].tsx @@ -164,13 +164,19 @@ export default function PlaylistScreen() { const startPlayback = (index: number) => { if (playable.length === 0) return; - void playTracks(playable.map(dbTrackToTrack), index); + void playTracks(playable.map(dbTrackToTrack), { + startIndex: index, + source: { kind: isFavorites ? 'favorites' : 'playlist', label: name }, + }); if (playlistId != null && !Number.isNaN(playlistId)) void markPlayed(playlistId); }; const startShuffle = () => { if (playable.length === 0) return; - void shuffleTracks(playable.map(dbTrackToTrack)); + void shuffleTracks(playable.map(dbTrackToTrack), { + kind: isFavorites ? 'favorites' : 'playlist', + label: name, + }); if (playlistId != null && !Number.isNaN(playlistId)) void markPlayed(playlistId); }; diff --git a/src/app/recently-played.tsx b/src/app/recently-played.tsx index 9dd0a1e..8dd41aa 100644 --- a/src/app/recently-played.tsx +++ b/src/app/recently-played.tsx @@ -46,7 +46,10 @@ export default function RecentlyPlayedScreen() { const playFrom = (index: number) => { if (tracks.length === 0) return; - void playTracks(tracks.map(dbTrackToTrack), index); + void playTracks(tracks.map(dbTrackToTrack), { + startIndex: index, + source: { kind: 'recently-played', label: 'Recently Played' }, + }); }; return ( diff --git a/src/app/signal/scan.tsx b/src/app/signal/scan.tsx index ae4df4e..6260d20 100644 --- a/src/app/signal/scan.tsx +++ b/src/app/signal/scan.tsx @@ -130,7 +130,9 @@ export default function SignalScanScreen() { setActionState('playing'); setActionError(null); try { - await playTracks([dbTrackToTrack(track)]); + await playTracks([dbTrackToTrack(track)], { + source: { kind: 'signal', label: 'Signal' }, + }); router.back(); } catch { setActionState('idle'); diff --git a/src/audio/playbackController.ts b/src/audio/playbackController.ts index ca22703..423cd7d 100644 --- a/src/audio/playbackController.ts +++ b/src/audio/playbackController.ts @@ -3,7 +3,7 @@ import TrackPlayer, { State, type Track as RntpTrack, } from 'react-native-track-player'; -import type { PlaybackState, Track } from '@/types/audio'; +import type { PlaybackSource, PlaybackState, Track } from '@/types/audio'; import { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore'; import { useQueueStore } from '@/stores/queueStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; @@ -46,6 +46,11 @@ setQueueLoadErrorHandler(() => { let originalOrder: string[] | null = null; let restoredMaterializationPromise: Promise | null = null; +export interface PlaybackStartOptions { + startIndex?: number; + source: PlaybackSource; +} + const NEXT_REPEAT: Record = { none: 'all', all: 'one', @@ -259,6 +264,7 @@ export function getPlaybackSessionSnapshot(): PlaybackSessionSnapshotV1 | null { originalOrderPaths: originalOrderPaths?.length === queuePaths.length ? originalOrderPaths : [...queuePaths], + source: queue.source, }; } @@ -268,7 +274,7 @@ export function restorePlaybackSession( const player = usePlayerStore.getState(); if (!session || session.tracks.length === 0) { originalOrder = null; - useQueueStore.getState().setSnapshot([], -1); + useQueueStore.getState().setSnapshot([], -1, { source: null }); player.reset(); player.setShuffle(false); player.setRepeat('none'); @@ -281,7 +287,9 @@ export function restorePlaybackSession( originalOrder = session.originalOrderPaths .map((path) => idByPath.get(path)) .filter((id): id is string => Boolean(id)); - useQueueStore.getState().setSnapshot(queueTracks, session.activeIndex); + useQueueStore.getState().setSnapshot(queueTracks, session.activeIndex, { + source: session.source, + }); player.setCurrentTrack(activeTrack); player.setProgress(session.position, activeTrack.duration); player.clearPendingSeek(); @@ -301,21 +309,28 @@ export async function hasActiveNativePlaybackSession(): Promise { } /** Replace the queue with the given tracks and start playing at startIndex. */ -export async function playTracks(tracks: Track[], startIndex = 0): Promise { - return playTracksInternal(tracks, startIndex, { allowBackgroundSetup: false }); +export async function playTracks( + tracks: Track[], + options: PlaybackStartOptions +): Promise { + return playTracksInternal(tracks, options, { allowBackgroundSetup: false }); } /** Android Auto can request playback while the React UI is not foregrounded. */ -export async function playTracksForCar(tracks: Track[], startIndex = 0): Promise { - return playTracksInternal(tracks, startIndex, { allowBackgroundSetup: true }); +export async function playTracksForCar( + tracks: Track[], + options: PlaybackStartOptions +): Promise { + return playTracksInternal(tracks, options, { allowBackgroundSetup: true }); } async function playTracksInternal( tracks: Track[], - startIndex: number, + startOptions: PlaybackStartOptions, options: { allowBackgroundSetup: boolean }, ): Promise { if (tracks.length === 0) return; + const startIndex = startOptions.startIndex ?? 0; selectPhonePlaybackTarget(); discardPendingRestoredSession(); await ensurePlayerReady({ ...options, materializeRestored: false }); @@ -328,7 +343,9 @@ async function playTracksInternal( } const queueTracks = ordered.map(toRntpTrack); const playbackTarget = dspTargetFromTrack(queueTracks[startIndex], 'none'); - useQueueStore.getState().setSnapshot(queueTracks, startIndex); + useQueueStore.getState().setSnapshot(queueTracks, startIndex, { + source: startOptions.source, + }); setOptimisticTrack(queueTracks[startIndex], 'loading'); try { await prepareAudioProcessingForPlayback(playbackTarget, 'queue-play'); @@ -343,7 +360,10 @@ async function playTracksInternal( } /** Shuffle a context and play from the top (the library/album "Shuffle" buttons). */ -export async function shuffleTracks(tracks: Track[]): Promise { +export async function shuffleTracks( + tracks: Track[], + source: PlaybackSource +): Promise { if (tracks.length === 0) return; selectPhonePlaybackTarget(); discardPendingRestoredSession(); @@ -352,7 +372,7 @@ export async function shuffleTracks(tracks: Track[]): Promise { usePlayerStore.getState().setShuffle(true); const queueTracks = shuffleArray(tracks).map(toRntpTrack); const playbackTarget = dspTargetFromTrack(queueTracks[0], 'none'); - useQueueStore.getState().setSnapshot(queueTracks, 0); + useQueueStore.getState().setSnapshot(queueTracks, 0, { source }); setOptimisticTrack(queueTracks[0], 'loading'); try { await prepareAudioProcessingForPlayback(playbackTarget, 'shuffle-play'); @@ -380,7 +400,9 @@ export async function playSample(): Promise { await prepareAudioProcessingForPlayback(playbackTarget, 'sample-play'); await TrackPlayer.add(sampleQueue); originalOrder = SAMPLE_TRACKS.map((t) => t.id); - useQueueStore.getState().setSnapshot(sampleQueue, 0); + useQueueStore.getState().setSnapshot(sampleQueue, 0, { + source: { kind: 'sample', label: 'Astra Sample' }, + }); setOptimisticTrack(sampleQueue[0], 'loading'); } else { const activeIndex = await TrackPlayer.getActiveTrackIndex(); diff --git a/src/audio/playbackSource.ts b/src/audio/playbackSource.ts new file mode 100644 index 0000000..54c52c9 --- /dev/null +++ b/src/audio/playbackSource.ts @@ -0,0 +1,32 @@ +import type { PlaybackSource, PlaybackSourceKind } from '../types/audio.ts'; + +const MAX_PLAYBACK_SOURCE_LABEL_LENGTH = 256; +const PLAYBACK_SOURCE_KINDS = new Set([ + 'album', + 'artist', + 'playlist', + 'favorites', + 'library', + 'folder', + 'recently-played', + 'search', + 'signal', + 'android-auto', + 'sample', +]); + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +/** Validates persisted playback context without trusting arbitrary session JSON. */ +export function normalizePlaybackSource(value: unknown): PlaybackSource | null { + if (!isRecord(value)) return null; + if (typeof value.kind !== 'string' || !PLAYBACK_SOURCE_KINDS.has(value.kind as PlaybackSourceKind)) { + return null; + } + if (typeof value.label !== 'string') return null; + const label = value.label.trim(); + if (!label || label.length > MAX_PLAYBACK_SOURCE_LABEL_LENGTH) return null; + return { kind: value.kind as PlaybackSourceKind, label }; +} diff --git a/src/car/carPlayback.ts b/src/car/carPlayback.ts index 8b79c9d..5b31d25 100644 --- a/src/car/carPlayback.ts +++ b/src/car/carPlayback.ts @@ -21,6 +21,7 @@ import { useLibraryStore } from '@/stores/libraryStore'; import { usePlaylistStore } from '@/stores/playlistStore'; import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; import { useSettingsStore } from '@/stores/settingsStore'; +import type { PlaybackSource } from '@/types/audio'; import type { DbTrack } from '@/types/library'; export interface CarMediaPayload { @@ -125,7 +126,10 @@ async function playMedia(media: CarMediaPayload): Promise { const db = await openLibraryDb(); const resolved = await resolveMediaTracks(db, media); if (!resolved || resolved.tracks.length === 0) return; - await playTracksForCar(resolved.tracks.map(dbTrackToTrack), resolved.startIndex); + await playTracksForCar(resolved.tracks.map(dbTrackToTrack), { + startIndex: resolved.startIndex, + source: resolved.source, + }); if (media.kind === 'playlist' && media.id != null) { await markPlaylistPlayed(db, media.id); } @@ -134,20 +138,62 @@ async function playMedia(media: CarMediaPayload): Promise { async function resolveMediaTracks( db: LibraryDatabase, media: CarMediaPayload, -): Promise<{ tracks: DbTrack[]; startIndex: number } | null> { +): Promise<{ tracks: DbTrack[]; startIndex: number; source: PlaybackSource } | null> { if (media.kind === 'track') { const context = contextFromTrack(media); const contextTracks = context ? await tracksForContext(db, context) : []; const startIndex = contextTracks.findIndex((track) => track.path === media.path); - if (contextTracks.length > 0 && startIndex >= 0) { - return { tracks: contextTracks, startIndex }; + if (context && contextTracks.length > 0 && startIndex >= 0) { + return { + tracks: contextTracks, + startIndex, + source: await sourceForContext(db, context, contextTracks), + }; } const track = media.path ? await getTrackByPath(db, media.path) : null; - return track ? { tracks: [track], startIndex: 0 } : null; + return track + ? { + tracks: [track], + startIndex: 0, + source: { kind: 'android-auto', label: 'Android Auto' }, + } + : null; } const tracks = await tracksForContext(db, media); - return tracks.length > 0 ? { tracks, startIndex: 0 } : null; + return tracks.length > 0 + ? { + tracks, + startIndex: 0, + source: await sourceForContext(db, media, tracks), + } + : null; +} + +async function sourceForContext( + db: LibraryDatabase, + media: CarMediaPayload, + tracks: readonly DbTrack[], +): Promise { + if (media.kind === 'section' && media.section === 'favorites') { + return { kind: 'favorites', label: 'Favorites' }; + } + if (media.kind === 'section' && media.section === 'recent') { + return { kind: 'recently-played', label: 'Recently Played' }; + } + if (media.kind === 'playlist') { + const playlist = media.id == null + ? null + : (await getPlaylists(db)).find((entry) => entry.id === media.id); + return { kind: 'playlist', label: playlist?.name ?? 'Playlist' }; + } + if (media.kind === 'album') { + return { kind: 'album', label: tracks[0]?.album?.trim() || 'Album' }; + } + if (media.kind === 'artist') { + return { kind: 'artist', label: media.key?.trim() || 'Artist' }; + } + return { kind: 'android-auto', label: 'Android Auto' }; } function contextFromTrack(media: CarMediaPayload): CarMediaPayload | null { diff --git a/src/components/library/FoldersView.tsx b/src/components/library/FoldersView.tsx index 1df9ad2..d842ed6 100644 --- a/src/components/library/FoldersView.tsx +++ b/src/components/library/FoldersView.tsx @@ -149,7 +149,10 @@ function FolderTrackRow({ const index = row.folderTracks.findIndex((track) => track.path === row.track.path); const playFolderTrack = () => { - void playTracks(row.folderTracks.map(dbTrackToTrack), Math.max(0, index)); + void playTracks(row.folderTracks.map(dbTrackToTrack), { + startIndex: Math.max(0, index), + source: { kind: 'folder', label: row.folderName }, + }); }; const openActions = (event: GestureResponderEvent) => { event.stopPropagation(); @@ -210,11 +213,16 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps) // Folder-level playback runs the whole subtree (subfolders included), in tree order. const playFolder = (node: FolderTreeNode) => { if (node.subtreeTracks.length === 0) return; - void playTracks(node.subtreeTracks.map(dbTrackToTrack), 0); + void playTracks(node.subtreeTracks.map(dbTrackToTrack), { + source: { kind: 'folder', label: node.name }, + }); }; const shuffleFolder = (node: FolderTreeNode) => { if (node.subtreeTracks.length === 0) return; - void shuffleTracks(node.subtreeTracks.map(dbTrackToTrack)); + void shuffleTracks(node.subtreeTracks.map(dbTrackToTrack), { + kind: 'folder', + label: node.name, + }); }; const playFolderNext = (node: FolderTreeNode) => { if (node.subtreeTracks.length === 0) return; diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index 75703fb..bd33a59 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -72,6 +72,7 @@ import { import { useLibraryStore } from '@/stores/libraryStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { usePlayerStore } from '@/stores/playerStore'; +import { useQueueStore } from '@/stores/queueStore'; import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { usePlayerUiStore } from '@/stores/playerUiStore'; @@ -148,6 +149,7 @@ export function NowPlayingOverlay() { const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode); const libraryTracks = useLibraryStore((s) => s.tracks); const track = usePlayerStore((s) => s.currentTrack); + const playbackSource = useQueueStore((s) => s.source); const playbackState = usePlayerStore((s) => s.playbackState); const shuffle = usePlayerStore((s) => s.shuffle); const repeat = usePlayerStore((s) => s.repeat); @@ -165,6 +167,7 @@ export function NowPlayingOverlay() { const phonePresentation = getPhonePlaybackPresentation({ track, playbackState, + source: playbackSource, }); const desktopPresentation = getDesktopPlaybackPresentation({ connection: desktopConnection, diff --git a/src/components/search/QuickSearchOverlay.tsx b/src/components/search/QuickSearchOverlay.tsx index 24aa282..712ef29 100644 --- a/src/components/search/QuickSearchOverlay.tsx +++ b/src/components/search/QuickSearchOverlay.tsx @@ -938,7 +938,12 @@ function QuickSearchPanel({ 0, context.findIndex((track) => track.path === result.track.path) ); - void playTracks(context.map(dbTrackToTrack), index); + void playTracks(context.map(dbTrackToTrack), { + startIndex: index, + source: hasQuery + ? { kind: 'search', label: `Search: ${trimmedQuery}` } + : { kind: 'recently-played', label: 'Recently Played' }, + }); return; } diff --git a/src/library/folderTree.test.mts b/src/library/folderTree.test.mts index 2103296..e65b50a 100644 --- a/src/library/folderTree.test.mts +++ b/src/library/folderTree.test.mts @@ -184,4 +184,19 @@ test('flattens only expanded folder nodes', () => { ), ['AstraTest', 'Album', 'Nested Song', 'Root Song'] ); + const expandedRows = flattenFolderTree(tree, new Set([root.id, album.id])); + const nestedTrackRow = expandedRows.find( + (row) => row.type === 'track' && row.track.title === 'Nested Song' + ); + const rootTrackRow = expandedRows.find( + (row) => row.type === 'track' && row.track.title === 'Root Song' + ); + assert.equal( + nestedTrackRow?.type === 'track' ? nestedTrackRow.folderName : null, + 'Album' + ); + assert.equal( + rootTrackRow?.type === 'track' ? rootTrackRow.folderName : null, + 'AstraTest' + ); }); diff --git a/src/library/folderTree.ts b/src/library/folderTree.ts index fc75913..0cbc79f 100644 --- a/src/library/folderTree.ts +++ b/src/library/folderTree.ts @@ -30,6 +30,7 @@ export type FlattenedFolderTreeRow = id: string; track: DbTrack; folderTracks: DbTrack[]; + folderName: string; depth: number; }; @@ -226,6 +227,7 @@ export function flattenFolderTree( id: `track:${track.id}`, track, folderTracks: node.tracks, + folderName: node.name, depth: node.depth + 1, }); } diff --git a/src/playback/playbackTargetPresentation.test.mts b/src/playback/playbackTargetPresentation.test.mts new file mode 100644 index 0000000..2e57716 --- /dev/null +++ b/src/playback/playbackTargetPresentation.test.mts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { getPhonePlaybackPresentation } from './playbackTargetPresentation.ts'; +import type { Track } from '../types/audio.ts'; + +const track: Track = { + id: 'track-1', + path: 'file:///track.flac', + title: 'Track', + artist: 'Artist', + album: 'Wrong Album Guess', + duration: 180, + format: 'FLAC', +}; + +test('phone presentation uses the explicit queue source instead of the track album', () => { + const presentation = getPhonePlaybackPresentation({ + track, + playbackState: 'playing', + source: { kind: 'favorites', label: 'Favorites' }, + }); + + assert.equal(presentation.sourceLabel, 'Favorites'); +}); + +test('phone presentation falls back to Queue when source context is missing', () => { + const presentation = getPhonePlaybackPresentation({ + track, + playbackState: 'paused', + source: null, + }); + + assert.equal(presentation.sourceLabel, 'Queue'); + assert.notEqual(presentation.sourceLabel, track.album); +}); diff --git a/src/playback/playbackTargetPresentation.ts b/src/playback/playbackTargetPresentation.ts index c108de1..a46565f 100644 --- a/src/playback/playbackTargetPresentation.ts +++ b/src/playback/playbackTargetPresentation.ts @@ -1,4 +1,4 @@ -import type { PlaybackState, Track } from '@/types/audio'; +import type { PlaybackSource, PlaybackState, Track } from '@/types/audio'; import type { DesktopRemoteConnection, DesktopRemoteNowPlayingSnapshot, @@ -67,6 +67,7 @@ export function hostFromBaseUrl(baseUrl: string): string { export function getPhonePlaybackPresentation({ track, playbackState, + source, // Live progress is subscribed by leaf components (MiniProgress, seek bars) so // parents don't re-render on the 2Hz tick; only the desktop presentation // carries snapshot-fed progress through here. @@ -75,12 +76,13 @@ export function getPhonePlaybackPresentation({ }: { track: Track | null; playbackState: PlaybackState; + source?: PlaybackSource | null; currentTime?: number; duration?: number; }): PlaybackPresentation { return { target: 'phone', - sourceLabel: track?.album?.trim() || 'This phone', + sourceLabel: source?.label || 'Queue', deviceLabel: 'This phone', title: track?.title || 'Nothing playing', subtitle: track?.artist || 'Start a track from Home', diff --git a/src/session/sessionPersistence.ts b/src/session/sessionPersistence.ts index a193943..d1fa379 100644 --- a/src/session/sessionPersistence.ts +++ b/src/session/sessionPersistence.ts @@ -147,6 +147,7 @@ export function installMobileSessionPersistence( state.tracks !== previous.tracks || state.activeIndex !== previous.activeIndex || state.hasSnapshot !== previous.hasSnapshot + || state.source !== previous.source ) { scheduleDebouncedSave(); } diff --git a/src/session/sessionState.test.mts b/src/session/sessionState.test.mts index 25c122e..73801b2 100644 --- a/src/session/sessionState.test.mts +++ b/src/session/sessionState.test.mts @@ -99,6 +99,7 @@ test('round trips a normalized versioned snapshot', () => { shuffle: true, repeat: 'all', originalOrderPaths: ['file:///b.flac', 'file:///a.flac'], + source: { kind: 'playlist', label: 'Playlist 11' }, }, }; @@ -133,9 +134,37 @@ test('rejects unknown versions and safely defaults corrupt fields', () => { shuffle: false, repeat: 'none', originalOrderPaths: ['file:///a.flac', 'file:///b.flac'], + source: null, }); }); +test('accepts legacy playback snapshots without a source and rejects malformed sources', () => { + const legacy = normalizeMobileSessionSnapshot({ + kind: MOBILE_SESSION_KIND, + schemaVersion: MOBILE_SESSION_SCHEMA_VERSION, + savedAt: 123, + lastStableHref: '/', + playback: { + queuePaths: ['file:///a.flac'], + activeIndex: 0, + position: 12, + shuffle: false, + repeat: 'none', + originalOrderPaths: ['file:///a.flac'], + }, + }); + assert.equal(legacy?.playback?.source, null); + + const malformed = normalizeMobileSessionSnapshot({ + ...legacy, + playback: { + ...legacy?.playback, + source: { kind: 'playlist', label: ' ' }, + }, + }); + assert.equal(malformed?.playback?.source, null); +}); + test('restores duplicates and clamps position to the current duration', () => { const resolved = resolvePlaybackSession( { @@ -164,11 +193,13 @@ test('chooses the next survivor when the active track disappeared, then the prev shuffle: false, repeat: 'none', originalOrderPaths: ['file:///a.flac', 'file:///missing.flac', 'file:///c.flac'], + source: { kind: 'favorites', label: 'Favorites' }, }, tracks ); assert.equal(next?.tracks[next.activeIndex].title, 'C'); assert.equal(next?.position, 0); + assert.deepEqual(next?.source, { kind: 'favorites', label: 'Favorites' }); const previous = resolvePlaybackSession( { diff --git a/src/session/sessionState.ts b/src/session/sessionState.ts index c6bb371..165c269 100644 --- a/src/session/sessionState.ts +++ b/src/session/sessionState.ts @@ -1,3 +1,6 @@ +import { normalizePlaybackSource } from '../audio/playbackSource.ts'; +import type { PlaybackSource } from '../types/audio.ts'; + export const MOBILE_SESSION_KIND = 'astra-mobile-session'; export const MOBILE_SESSION_SCHEMA_VERSION = 1; @@ -15,6 +18,8 @@ export interface PlaybackSessionSnapshotV1 { shuffle: boolean; repeat: SessionRepeatMode; originalOrderPaths: string[]; + /** Optional for backward compatibility with version-1 snapshots written before queue sources. */ + source?: PlaybackSource | null; } export interface MobileSessionSnapshotV1 { @@ -37,6 +42,7 @@ export interface ResolvedPlaybackSession { shuffle: boolean; repeat: SessionRepeatMode; originalOrderPaths: string[]; + source: PlaybackSource | null; } export interface StableRouteValidationContext { @@ -280,6 +286,7 @@ export function normalizePlaybackSession(value: unknown): PlaybackSessionSnapsho shuffle: value.shuffle === true, repeat: normalizeRepeat(value.repeat), originalOrderPaths, + source: normalizePlaybackSource(value.source), }; } @@ -358,5 +365,6 @@ export function resolvePlaybackSession( originalOrderPaths: samePathMultiset(originalOrderPaths, resolvedQueuePaths) ? originalOrderPaths : resolvedQueuePaths, + source: snapshot.source ?? null, }; } diff --git a/src/stores/queueStore.ts b/src/stores/queueStore.ts index f49b83c..1405da0 100644 --- a/src/stores/queueStore.ts +++ b/src/stores/queueStore.ts @@ -1,6 +1,12 @@ import { create } from 'zustand'; import TrackPlayer, { type Track as RntpTrack } from 'react-native-track-player'; import { nativeIndexToAbsolute, queueLoadSettled } from '@/audio/queueLoader'; +import type { PlaybackSource } from '@/types/audio'; + +interface QueueSnapshotOptions { + /** Omit to retain the current queue source; pass null when clearing playback. */ + source?: PlaybackSource | null; +} /** * Live mirror of RNTP's native queue for the queue tray. Playback actions keep @@ -11,9 +17,14 @@ interface QueueStore { tracks: RntpTrack[]; activeIndex: number; hasSnapshot: boolean; + source: PlaybackSource | null; refreshFromNative: () => Promise; refreshActiveIndex: () => Promise; - setSnapshot: (tracks: RntpTrack[], activeIndex?: number) => void; + setSnapshot: ( + tracks: RntpTrack[], + activeIndex?: number, + options?: QueueSnapshotOptions + ) => void; setActiveIndex: (activeIndex: number) => void; insertTrack: (track: RntpTrack, index?: number) => void; replaceUpcoming: (upcoming: RntpTrack[]) => void; @@ -35,6 +46,7 @@ export const useQueueStore = create((set) => ({ tracks: [], activeIndex: -1, hasSnapshot: false, + source: null, refreshFromNative: async () => { // Mid chunked-load the native queue is partial and index-shifted — wait it out. await queueLoadSettled(); @@ -57,12 +69,15 @@ export const useQueueStore = create((set) => ({ ), })); }, - setSnapshot: (tracks, activeIndex = 0) => - set({ + setSnapshot: (tracks, activeIndex = 0, options) => + set((state) => ({ tracks, activeIndex: normalizeActiveIndex(activeIndex, tracks.length), hasSnapshot: true, - }), + source: options && Object.hasOwn(options, 'source') + ? options.source ?? null + : state.source, + })), setActiveIndex: (activeIndex) => set((s) => ({ activeIndex: normalizeActiveIndex(activeIndex, s.tracks.length) })), insertTrack: (track, index) => diff --git a/src/types/audio.ts b/src/types/audio.ts index f3f62a6..c6d4faf 100644 --- a/src/types/audio.ts +++ b/src/types/audio.ts @@ -40,6 +40,25 @@ export interface Track { availabilityReason?: string; } +export type PlaybackSourceKind = + | 'album' + | 'artist' + | 'playlist' + | 'favorites' + | 'library' + | 'folder' + | 'recently-played' + | 'search' + | 'signal' + | 'android-auto' + | 'sample'; + +/** The collection or surface that created the current playback queue. */ +export interface PlaybackSource { + kind: PlaybackSourceKind; + label: string; +} + // Playback state export type PlaybackState = 'stopped' | 'playing' | 'paused' | 'loading';