mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
fix now playing source label
This commit is contained in:
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user