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: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",
+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;
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)}
/>
+8 -2
View File
@@ -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}
+8 -2
View File
@@ -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) => {
@@ -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 (
@@ -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 (
+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.
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();
+8 -2
View File
@@ -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);
};
+4 -1
View File
@@ -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 (
+3 -1
View File
@@ -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');
+34 -12
View File
@@ -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<void> | null = null;
export interface PlaybackStartOptions {
startIndex?: number;
source: PlaybackSource;
}
const NEXT_REPEAT: Record<RepeatModeStr, RepeatModeStr> = {
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<boolean> {
}
/** Replace the queue with the given tracks and start playing at startIndex. */
export async function playTracks(tracks: Track[], startIndex = 0): Promise<void> {
return playTracksInternal(tracks, startIndex, { allowBackgroundSetup: false });
export async function playTracks(
tracks: Track[],
options: PlaybackStartOptions
): Promise<void> {
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<void> {
return playTracksInternal(tracks, startIndex, { allowBackgroundSetup: true });
export async function playTracksForCar(
tracks: Track[],
options: PlaybackStartOptions
): Promise<void> {
return playTracksInternal(tracks, options, { allowBackgroundSetup: true });
}
async function playTracksInternal(
tracks: Track[],
startIndex: number,
startOptions: PlaybackStartOptions,
options: { allowBackgroundSetup: boolean },
): Promise<void> {
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<void> {
export async function shuffleTracks(
tracks: Track[],
source: PlaybackSource
): Promise<void> {
if (tracks.length === 0) return;
selectPhonePlaybackTarget();
discardPendingRestoredSession();
@@ -352,7 +372,7 @@ export async function shuffleTracks(tracks: Track[]): Promise<void> {
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<void> {
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();
+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 { 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<void> {
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<void> {
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<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 {
+11 -3
View File
@@ -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;
@@ -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,
+6 -1
View File
@@ -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;
}
+15
View File
@@ -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'
);
});
+2
View File
@@ -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,
});
}
@@ -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 {
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',
+1
View File
@@ -147,6 +147,7 @@ export function installMobileSessionPersistence(
state.tracks !== previous.tracks
|| state.activeIndex !== previous.activeIndex
|| state.hasSnapshot !== previous.hasSnapshot
|| state.source !== previous.source
) {
scheduleDebouncedSave();
}
+31
View File
@@ -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(
{
+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_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<T extends SessionTrackLike> {
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<T extends SessionTrackLike>(
originalOrderPaths: samePathMultiset(originalOrderPaths, resolvedQueuePaths)
? originalOrderPaths
: resolvedQueuePaths,
source: snapshot.source ?? null,
};
}
+19 -4
View File
@@ -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<void>;
refreshActiveIndex: () => Promise<void>;
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<QueueStore>((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<QueueStore>((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) =>
+19
View File
@@ -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';