fix now playing source label

This commit is contained in:
Boof2015
2026-07-15 20:02:10 -04:00
parent 89c6bac546
commit 26fba836f4
25 changed files with 327 additions and 45 deletions
+1 -1
View File
@@ -82,7 +82,7 @@
"test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts", "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: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: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: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: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", "test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts",
+11 -5
View File
@@ -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; 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) => { const playSpotlight = (shuffled = false) => {
if (spotlightTracks.length === 0) return; 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) { if (shuffled) {
void shuffleTracks(spotlightTracks.map(dbTrackToTrack)); void shuffleTracks(spotlightTracks.map(dbTrackToTrack), source);
} else { } else {
void playTracks(spotlightTracks.map(dbTrackToTrack), 0); void playTracks(spotlightTracks.map(dbTrackToTrack), { source });
} }
}; };
@@ -682,7 +688,7 @@ export default function HomeScreen() {
track={track} track={track}
active={track.path === currentPath} active={track.path === currentPath}
swipeToQueue={false} swipeToQueue={false}
onPress={() => playTrackList(recentTracks, index)} onPress={() => playRecentlyPlayed(recentTracks, index)}
onLongPress={() => setActionTrack(track)} onLongPress={() => setActionTrack(track)}
onOpenActions={() => setActionTrack(track)} onOpenActions={() => setActionTrack(track)}
/> />
+8 -2
View File
@@ -84,7 +84,10 @@ export default function AlbumScreen() {
}, [tracks]); }, [tracks]);
const playFrom = (index: number) => { 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 // 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} disabled={tracks.length === 0}
onBack={handleBack} onBack={handleBack}
onPlay={() => playFrom(0)} 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} scrollY={scrollY}
heroFaded={heroFaded} heroFaded={heroFaded}
collapsed={collapsed} collapsed={collapsed}
+8 -2
View File
@@ -88,13 +88,19 @@ export default function ArtistScreen() {
const playTrackListFrom = (tracks: readonly DbTrack[], index: number) => { const playTrackListFrom = (tracks: readonly DbTrack[], index: number) => {
if (tracks.length === 0) return; 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 playArtist = () => playTrackListFrom(detail.playbackTracks, 0);
const shuffleArtist = () => { const shuffleArtist = () => {
if (detail.playbackTracks.length === 0) return; 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) => { const openSection = (target: ArtistSectionTarget) => {
@@ -44,7 +44,10 @@ export default function ArtistAppearancesScreen() {
const playFrom = (index: number) => { const playFrom = (index: number) => {
if (tracks.length === 0) return; if (tracks.length === 0) return;
void playTracks(tracks.map(dbTrackToTrack), index); void playTracks(tracks.map(dbTrackToTrack), {
startIndex: index,
source: { kind: 'artist', label: name },
});
}; };
return ( return (
@@ -44,7 +44,10 @@ export default function ArtistSongsScreen() {
const playFrom = (index: number) => { const playFrom = (index: number) => {
if (tracks.length === 0) return; if (tracks.length === 0) return;
void playTracks(tracks.map(dbTrackToTrack), index); void playTracks(tracks.map(dbTrackToTrack), {
startIndex: index,
source: { kind: 'artist', label: name },
});
}; };
return ( return (
+4 -1
View File
@@ -133,7 +133,10 @@ export default function LibraryScreen() {
// Tap index is within sortedTracks so the tapped row is the track that plays. // Tap index is within sortedTracks so the tapped row is the track that plays.
const playAllFrom = (index: number) => { 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(); const openSearch = () => openQuickSearch();
+8 -2
View File
@@ -164,13 +164,19 @@ export default function PlaylistScreen() {
const startPlayback = (index: number) => { const startPlayback = (index: number) => {
if (playable.length === 0) return; 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); if (playlistId != null && !Number.isNaN(playlistId)) void markPlayed(playlistId);
}; };
const startShuffle = () => { const startShuffle = () => {
if (playable.length === 0) return; 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); if (playlistId != null && !Number.isNaN(playlistId)) void markPlayed(playlistId);
}; };
+4 -1
View File
@@ -46,7 +46,10 @@ export default function RecentlyPlayedScreen() {
const playFrom = (index: number) => { const playFrom = (index: number) => {
if (tracks.length === 0) return; 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 ( return (
+3 -1
View File
@@ -130,7 +130,9 @@ export default function SignalScanScreen() {
setActionState('playing'); setActionState('playing');
setActionError(null); setActionError(null);
try { try {
await playTracks([dbTrackToTrack(track)]); await playTracks([dbTrackToTrack(track)], {
source: { kind: 'signal', label: 'Signal' },
});
router.back(); router.back();
} catch { } catch {
setActionState('idle'); setActionState('idle');
+34 -12
View File
@@ -3,7 +3,7 @@ import TrackPlayer, {
State, State,
type Track as RntpTrack, type Track as RntpTrack,
} from 'react-native-track-player'; } 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 { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore';
import { useQueueStore } from '@/stores/queueStore'; import { useQueueStore } from '@/stores/queueStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
@@ -46,6 +46,11 @@ setQueueLoadErrorHandler(() => {
let originalOrder: string[] | null = null; let originalOrder: string[] | null = null;
let restoredMaterializationPromise: Promise<void> | null = null; let restoredMaterializationPromise: Promise<void> | null = null;
export interface PlaybackStartOptions {
startIndex?: number;
source: PlaybackSource;
}
const NEXT_REPEAT: Record<RepeatModeStr, RepeatModeStr> = { const NEXT_REPEAT: Record<RepeatModeStr, RepeatModeStr> = {
none: 'all', none: 'all',
all: 'one', all: 'one',
@@ -259,6 +264,7 @@ export function getPlaybackSessionSnapshot(): PlaybackSessionSnapshotV1 | null {
originalOrderPaths: originalOrderPaths?.length === queuePaths.length originalOrderPaths: originalOrderPaths?.length === queuePaths.length
? originalOrderPaths ? originalOrderPaths
: [...queuePaths], : [...queuePaths],
source: queue.source,
}; };
} }
@@ -268,7 +274,7 @@ export function restorePlaybackSession(
const player = usePlayerStore.getState(); const player = usePlayerStore.getState();
if (!session || session.tracks.length === 0) { if (!session || session.tracks.length === 0) {
originalOrder = null; originalOrder = null;
useQueueStore.getState().setSnapshot([], -1); useQueueStore.getState().setSnapshot([], -1, { source: null });
player.reset(); player.reset();
player.setShuffle(false); player.setShuffle(false);
player.setRepeat('none'); player.setRepeat('none');
@@ -281,7 +287,9 @@ export function restorePlaybackSession(
originalOrder = session.originalOrderPaths originalOrder = session.originalOrderPaths
.map((path) => idByPath.get(path)) .map((path) => idByPath.get(path))
.filter((id): id is string => Boolean(id)); .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.setCurrentTrack(activeTrack);
player.setProgress(session.position, activeTrack.duration); player.setProgress(session.position, activeTrack.duration);
player.clearPendingSeek(); player.clearPendingSeek();
@@ -301,21 +309,28 @@ export async function hasActiveNativePlaybackSession(): Promise<boolean> {
} }
/** Replace the queue with the given tracks and start playing at startIndex. */ /** Replace the queue with the given tracks and start playing at startIndex. */
export async function playTracks(tracks: Track[], startIndex = 0): Promise<void> { export async function playTracks(
return playTracksInternal(tracks, startIndex, { allowBackgroundSetup: false }); tracks: Track[],
options: PlaybackStartOptions
): Promise<void> {
return playTracksInternal(tracks, options, { allowBackgroundSetup: false });
} }
/** Android Auto can request playback while the React UI is not foregrounded. */ /** Android Auto can request playback while the React UI is not foregrounded. */
export async function playTracksForCar(tracks: Track[], startIndex = 0): Promise<void> { export async function playTracksForCar(
return playTracksInternal(tracks, startIndex, { allowBackgroundSetup: true }); tracks: Track[],
options: PlaybackStartOptions
): Promise<void> {
return playTracksInternal(tracks, options, { allowBackgroundSetup: true });
} }
async function playTracksInternal( async function playTracksInternal(
tracks: Track[], tracks: Track[],
startIndex: number, startOptions: PlaybackStartOptions,
options: { allowBackgroundSetup: boolean }, options: { allowBackgroundSetup: boolean },
): Promise<void> { ): Promise<void> {
if (tracks.length === 0) return; if (tracks.length === 0) return;
const startIndex = startOptions.startIndex ?? 0;
selectPhonePlaybackTarget(); selectPhonePlaybackTarget();
discardPendingRestoredSession(); discardPendingRestoredSession();
await ensurePlayerReady({ ...options, materializeRestored: false }); await ensurePlayerReady({ ...options, materializeRestored: false });
@@ -328,7 +343,9 @@ async function playTracksInternal(
} }
const queueTracks = ordered.map(toRntpTrack); const queueTracks = ordered.map(toRntpTrack);
const playbackTarget = dspTargetFromTrack(queueTracks[startIndex], 'none'); const playbackTarget = dspTargetFromTrack(queueTracks[startIndex], 'none');
useQueueStore.getState().setSnapshot(queueTracks, startIndex); useQueueStore.getState().setSnapshot(queueTracks, startIndex, {
source: startOptions.source,
});
setOptimisticTrack(queueTracks[startIndex], 'loading'); setOptimisticTrack(queueTracks[startIndex], 'loading');
try { try {
await prepareAudioProcessingForPlayback(playbackTarget, 'queue-play'); 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). */ /** Shuffle a context and play from the top (the library/album "Shuffle" buttons). */
export async function shuffleTracks(tracks: Track[]): Promise<void> { export async function shuffleTracks(
tracks: Track[],
source: PlaybackSource
): Promise<void> {
if (tracks.length === 0) return; if (tracks.length === 0) return;
selectPhonePlaybackTarget(); selectPhonePlaybackTarget();
discardPendingRestoredSession(); discardPendingRestoredSession();
@@ -352,7 +372,7 @@ export async function shuffleTracks(tracks: Track[]): Promise<void> {
usePlayerStore.getState().setShuffle(true); usePlayerStore.getState().setShuffle(true);
const queueTracks = shuffleArray(tracks).map(toRntpTrack); const queueTracks = shuffleArray(tracks).map(toRntpTrack);
const playbackTarget = dspTargetFromTrack(queueTracks[0], 'none'); const playbackTarget = dspTargetFromTrack(queueTracks[0], 'none');
useQueueStore.getState().setSnapshot(queueTracks, 0); useQueueStore.getState().setSnapshot(queueTracks, 0, { source });
setOptimisticTrack(queueTracks[0], 'loading'); setOptimisticTrack(queueTracks[0], 'loading');
try { try {
await prepareAudioProcessingForPlayback(playbackTarget, 'shuffle-play'); await prepareAudioProcessingForPlayback(playbackTarget, 'shuffle-play');
@@ -380,7 +400,9 @@ export async function playSample(): Promise<void> {
await prepareAudioProcessingForPlayback(playbackTarget, 'sample-play'); await prepareAudioProcessingForPlayback(playbackTarget, 'sample-play');
await TrackPlayer.add(sampleQueue); await TrackPlayer.add(sampleQueue);
originalOrder = SAMPLE_TRACKS.map((t) => t.id); 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'); setOptimisticTrack(sampleQueue[0], 'loading');
} else { } else {
const activeIndex = await TrackPlayer.getActiveTrackIndex(); const activeIndex = await TrackPlayer.getActiveTrackIndex();
+32
View File
@@ -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<PlaybackSourceKind>([
'album',
'artist',
'playlist',
'favorites',
'library',
'folder',
'recently-played',
'search',
'signal',
'android-auto',
'sample',
]);
function isRecord(value: unknown): value is Record<string, unknown> {
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 };
}
+52 -6
View File
@@ -21,6 +21,7 @@ import { useLibraryStore } from '@/stores/libraryStore';
import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlaylistStore } from '@/stores/playlistStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import { useSettingsStore } from '@/stores/settingsStore'; import { useSettingsStore } from '@/stores/settingsStore';
import type { PlaybackSource } from '@/types/audio';
import type { DbTrack } from '@/types/library'; import type { DbTrack } from '@/types/library';
export interface CarMediaPayload { export interface CarMediaPayload {
@@ -125,7 +126,10 @@ async function playMedia(media: CarMediaPayload): Promise<void> {
const db = await openLibraryDb(); const db = await openLibraryDb();
const resolved = await resolveMediaTracks(db, media); const resolved = await resolveMediaTracks(db, media);
if (!resolved || resolved.tracks.length === 0) return; 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) { if (media.kind === 'playlist' && media.id != null) {
await markPlaylistPlayed(db, media.id); await markPlaylistPlayed(db, media.id);
} }
@@ -134,20 +138,62 @@ async function playMedia(media: CarMediaPayload): Promise<void> {
async function resolveMediaTracks( async function resolveMediaTracks(
db: LibraryDatabase, db: LibraryDatabase,
media: CarMediaPayload, media: CarMediaPayload,
): Promise<{ tracks: DbTrack[]; startIndex: number } | null> { ): Promise<{ tracks: DbTrack[]; startIndex: number; source: PlaybackSource } | null> {
if (media.kind === 'track') { if (media.kind === 'track') {
const context = contextFromTrack(media); const context = contextFromTrack(media);
const contextTracks = context ? await tracksForContext(db, context) : []; const contextTracks = context ? await tracksForContext(db, context) : [];
const startIndex = contextTracks.findIndex((track) => track.path === media.path); const startIndex = contextTracks.findIndex((track) => track.path === media.path);
if (contextTracks.length > 0 && startIndex >= 0) { if (context && contextTracks.length > 0 && startIndex >= 0) {
return { tracks: contextTracks, startIndex }; return {
tracks: contextTracks,
startIndex,
source: await sourceForContext(db, context, contextTracks),
};
} }
const track = media.path ? await getTrackByPath(db, media.path) : null; 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); 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<PlaybackSource> {
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 { function contextFromTrack(media: CarMediaPayload): CarMediaPayload | null {
+11 -3
View File
@@ -149,7 +149,10 @@ function FolderTrackRow({
const index = row.folderTracks.findIndex((track) => track.path === row.track.path); const index = row.folderTracks.findIndex((track) => track.path === row.track.path);
const playFolderTrack = () => { 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) => { const openActions = (event: GestureResponderEvent) => {
event.stopPropagation(); event.stopPropagation();
@@ -210,11 +213,16 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps)
// Folder-level playback runs the whole subtree (subfolders included), in tree order. // Folder-level playback runs the whole subtree (subfolders included), in tree order.
const playFolder = (node: FolderTreeNode) => { const playFolder = (node: FolderTreeNode) => {
if (node.subtreeTracks.length === 0) return; 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) => { const shuffleFolder = (node: FolderTreeNode) => {
if (node.subtreeTracks.length === 0) return; 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) => { const playFolderNext = (node: FolderTreeNode) => {
if (node.subtreeTracks.length === 0) return; if (node.subtreeTracks.length === 0) return;
@@ -72,6 +72,7 @@ import {
import { useLibraryStore } from '@/stores/libraryStore'; import { useLibraryStore } from '@/stores/libraryStore';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { useQueueStore } from '@/stores/queueStore';
import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { usePlayerUiStore } from '@/stores/playerUiStore'; import { usePlayerUiStore } from '@/stores/playerUiStore';
@@ -148,6 +149,7 @@ export function NowPlayingOverlay() {
const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode); const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode);
const libraryTracks = useLibraryStore((s) => s.tracks); const libraryTracks = useLibraryStore((s) => s.tracks);
const track = usePlayerStore((s) => s.currentTrack); const track = usePlayerStore((s) => s.currentTrack);
const playbackSource = useQueueStore((s) => s.source);
const playbackState = usePlayerStore((s) => s.playbackState); const playbackState = usePlayerStore((s) => s.playbackState);
const shuffle = usePlayerStore((s) => s.shuffle); const shuffle = usePlayerStore((s) => s.shuffle);
const repeat = usePlayerStore((s) => s.repeat); const repeat = usePlayerStore((s) => s.repeat);
@@ -165,6 +167,7 @@ export function NowPlayingOverlay() {
const phonePresentation = getPhonePlaybackPresentation({ const phonePresentation = getPhonePlaybackPresentation({
track, track,
playbackState, playbackState,
source: playbackSource,
}); });
const desktopPresentation = getDesktopPlaybackPresentation({ const desktopPresentation = getDesktopPlaybackPresentation({
connection: desktopConnection, connection: desktopConnection,
+6 -1
View File
@@ -938,7 +938,12 @@ function QuickSearchPanel({
0, 0,
context.findIndex((track) => track.path === result.track.path) 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; return;
} }
+15
View File
@@ -184,4 +184,19 @@ test('flattens only expanded folder nodes', () => {
), ),
['AstraTest', 'Album', 'Nested Song', 'Root Song'] ['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'
);
}); });
+2
View File
@@ -30,6 +30,7 @@ export type FlattenedFolderTreeRow =
id: string; id: string;
track: DbTrack; track: DbTrack;
folderTracks: DbTrack[]; folderTracks: DbTrack[];
folderName: string;
depth: number; depth: number;
}; };
@@ -226,6 +227,7 @@ export function flattenFolderTree(
id: `track:${track.id}`, id: `track:${track.id}`,
track, track,
folderTracks: node.tracks, folderTracks: node.tracks,
folderName: node.name,
depth: node.depth + 1, depth: node.depth + 1,
}); });
} }
@@ -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);
});
+4 -2
View File
@@ -1,4 +1,4 @@
import type { PlaybackState, Track } from '@/types/audio'; import type { PlaybackSource, PlaybackState, Track } from '@/types/audio';
import type { import type {
DesktopRemoteConnection, DesktopRemoteConnection,
DesktopRemoteNowPlayingSnapshot, DesktopRemoteNowPlayingSnapshot,
@@ -67,6 +67,7 @@ export function hostFromBaseUrl(baseUrl: string): string {
export function getPhonePlaybackPresentation({ export function getPhonePlaybackPresentation({
track, track,
playbackState, playbackState,
source,
// Live progress is subscribed by leaf components (MiniProgress, seek bars) so // Live progress is subscribed by leaf components (MiniProgress, seek bars) so
// parents don't re-render on the 2Hz tick; only the desktop presentation // parents don't re-render on the 2Hz tick; only the desktop presentation
// carries snapshot-fed progress through here. // carries snapshot-fed progress through here.
@@ -75,12 +76,13 @@ export function getPhonePlaybackPresentation({
}: { }: {
track: Track | null; track: Track | null;
playbackState: PlaybackState; playbackState: PlaybackState;
source?: PlaybackSource | null;
currentTime?: number; currentTime?: number;
duration?: number; duration?: number;
}): PlaybackPresentation { }): PlaybackPresentation {
return { return {
target: 'phone', target: 'phone',
sourceLabel: track?.album?.trim() || 'This phone', sourceLabel: source?.label || 'Queue',
deviceLabel: 'This phone', deviceLabel: 'This phone',
title: track?.title || 'Nothing playing', title: track?.title || 'Nothing playing',
subtitle: track?.artist || 'Start a track from Home', subtitle: track?.artist || 'Start a track from Home',
+1
View File
@@ -147,6 +147,7 @@ export function installMobileSessionPersistence(
state.tracks !== previous.tracks state.tracks !== previous.tracks
|| state.activeIndex !== previous.activeIndex || state.activeIndex !== previous.activeIndex
|| state.hasSnapshot !== previous.hasSnapshot || state.hasSnapshot !== previous.hasSnapshot
|| state.source !== previous.source
) { ) {
scheduleDebouncedSave(); scheduleDebouncedSave();
} }
+31
View File
@@ -99,6 +99,7 @@ test('round trips a normalized versioned snapshot', () => {
shuffle: true, shuffle: true,
repeat: 'all', repeat: 'all',
originalOrderPaths: ['file:///b.flac', 'file:///a.flac'], 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, shuffle: false,
repeat: 'none', repeat: 'none',
originalOrderPaths: ['file:///a.flac', 'file:///b.flac'], 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', () => { test('restores duplicates and clamps position to the current duration', () => {
const resolved = resolvePlaybackSession( const resolved = resolvePlaybackSession(
{ {
@@ -164,11 +193,13 @@ test('chooses the next survivor when the active track disappeared, then the prev
shuffle: false, shuffle: false,
repeat: 'none', repeat: 'none',
originalOrderPaths: ['file:///a.flac', 'file:///missing.flac', 'file:///c.flac'], originalOrderPaths: ['file:///a.flac', 'file:///missing.flac', 'file:///c.flac'],
source: { kind: 'favorites', label: 'Favorites' },
}, },
tracks tracks
); );
assert.equal(next?.tracks[next.activeIndex].title, 'C'); assert.equal(next?.tracks[next.activeIndex].title, 'C');
assert.equal(next?.position, 0); assert.equal(next?.position, 0);
assert.deepEqual(next?.source, { kind: 'favorites', label: 'Favorites' });
const previous = resolvePlaybackSession( const previous = resolvePlaybackSession(
{ {
+8
View File
@@ -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_KIND = 'astra-mobile-session';
export const MOBILE_SESSION_SCHEMA_VERSION = 1; export const MOBILE_SESSION_SCHEMA_VERSION = 1;
@@ -15,6 +18,8 @@ export interface PlaybackSessionSnapshotV1 {
shuffle: boolean; shuffle: boolean;
repeat: SessionRepeatMode; repeat: SessionRepeatMode;
originalOrderPaths: string[]; originalOrderPaths: string[];
/** Optional for backward compatibility with version-1 snapshots written before queue sources. */
source?: PlaybackSource | null;
} }
export interface MobileSessionSnapshotV1 { export interface MobileSessionSnapshotV1 {
@@ -37,6 +42,7 @@ export interface ResolvedPlaybackSession<T extends SessionTrackLike> {
shuffle: boolean; shuffle: boolean;
repeat: SessionRepeatMode; repeat: SessionRepeatMode;
originalOrderPaths: string[]; originalOrderPaths: string[];
source: PlaybackSource | null;
} }
export interface StableRouteValidationContext { export interface StableRouteValidationContext {
@@ -280,6 +286,7 @@ export function normalizePlaybackSession(value: unknown): PlaybackSessionSnapsho
shuffle: value.shuffle === true, shuffle: value.shuffle === true,
repeat: normalizeRepeat(value.repeat), repeat: normalizeRepeat(value.repeat),
originalOrderPaths, originalOrderPaths,
source: normalizePlaybackSource(value.source),
}; };
} }
@@ -358,5 +365,6 @@ export function resolvePlaybackSession<T extends SessionTrackLike>(
originalOrderPaths: samePathMultiset(originalOrderPaths, resolvedQueuePaths) originalOrderPaths: samePathMultiset(originalOrderPaths, resolvedQueuePaths)
? originalOrderPaths ? originalOrderPaths
: resolvedQueuePaths, : resolvedQueuePaths,
source: snapshot.source ?? null,
}; };
} }
+19 -4
View File
@@ -1,6 +1,12 @@
import { create } from 'zustand'; import { create } from 'zustand';
import TrackPlayer, { type Track as RntpTrack } from 'react-native-track-player'; import TrackPlayer, { type Track as RntpTrack } from 'react-native-track-player';
import { nativeIndexToAbsolute, queueLoadSettled } from '@/audio/queueLoader'; 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 * Live mirror of RNTP's native queue for the queue tray. Playback actions keep
@@ -11,9 +17,14 @@ interface QueueStore {
tracks: RntpTrack[]; tracks: RntpTrack[];
activeIndex: number; activeIndex: number;
hasSnapshot: boolean; hasSnapshot: boolean;
source: PlaybackSource | null;
refreshFromNative: () => Promise<void>; refreshFromNative: () => Promise<void>;
refreshActiveIndex: () => Promise<void>; refreshActiveIndex: () => Promise<void>;
setSnapshot: (tracks: RntpTrack[], activeIndex?: number) => void; setSnapshot: (
tracks: RntpTrack[],
activeIndex?: number,
options?: QueueSnapshotOptions
) => void;
setActiveIndex: (activeIndex: number) => void; setActiveIndex: (activeIndex: number) => void;
insertTrack: (track: RntpTrack, index?: number) => void; insertTrack: (track: RntpTrack, index?: number) => void;
replaceUpcoming: (upcoming: RntpTrack[]) => void; replaceUpcoming: (upcoming: RntpTrack[]) => void;
@@ -35,6 +46,7 @@ export const useQueueStore = create<QueueStore>((set) => ({
tracks: [], tracks: [],
activeIndex: -1, activeIndex: -1,
hasSnapshot: false, hasSnapshot: false,
source: null,
refreshFromNative: async () => { refreshFromNative: async () => {
// Mid chunked-load the native queue is partial and index-shifted — wait it out. // Mid chunked-load the native queue is partial and index-shifted — wait it out.
await queueLoadSettled(); await queueLoadSettled();
@@ -57,12 +69,15 @@ export const useQueueStore = create<QueueStore>((set) => ({
), ),
})); }));
}, },
setSnapshot: (tracks, activeIndex = 0) => setSnapshot: (tracks, activeIndex = 0, options) =>
set({ set((state) => ({
tracks, tracks,
activeIndex: normalizeActiveIndex(activeIndex, tracks.length), activeIndex: normalizeActiveIndex(activeIndex, tracks.length),
hasSnapshot: true, hasSnapshot: true,
}), source: options && Object.hasOwn(options, 'source')
? options.source ?? null
: state.source,
})),
setActiveIndex: (activeIndex) => setActiveIndex: (activeIndex) =>
set((s) => ({ activeIndex: normalizeActiveIndex(activeIndex, s.tracks.length) })), set((s) => ({ activeIndex: normalizeActiveIndex(activeIndex, s.tracks.length) })),
insertTrack: (track, index) => insertTrack: (track, index) =>
+19
View File
@@ -40,6 +40,25 @@ export interface Track {
availabilityReason?: string; 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 // Playback state
export type PlaybackState = 'stopped' | 'playing' | 'paused' | 'loading'; export type PlaybackState = 'stopped' | 'playing' | 'paused' | 'loading';