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: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",
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 { 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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -147,6 +147,7 @@ export function installMobileSessionPersistence(
|
||||
state.tracks !== previous.tracks
|
||||
|| state.activeIndex !== previous.activeIndex
|
||||
|| state.hasSnapshot !== previous.hasSnapshot
|
||||
|| state.source !== previous.source
|
||||
) {
|
||||
scheduleDebouncedSave();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user