fix(queue): persist timeline play history across queue replace (#1096) (#1204)

* fix(queue): persist timeline play history across queue replace (#1096)

Add a session-scoped play-history buffer (with play_session cold bootstrap)
and timeline UI that shows history + current + upcoming without mutating
the canonical queue or Subsonic sync.

* fix(queue): pin timeline current to top and replay history in-place

Timeline scroll matches queue mode (current at top). History clicks insert
after the playing track instead of replacing the queue, and replayed tracks
stay visible in the history strip.

* docs: add CHANGELOG and credits for timeline play history (PR #1204)

* fix(queue): resolve cross-server cover art for timeline history

Include album/cover ids in play_session bootstrap rows, prefetch history
refs through the queue resolver per server, and resolve before replay so
Now Playing artwork works for inactive-server tracks.

* fix(now-playing): stop playbackReport on cross-server track switch

Send stopped to the previous server's playbackReport session when the
playback server changes (queue click, history replay, etc.) so Who is
listening clears on the server that was showing the prior track.

* fix(queue): close timeline history review gaps for PR #1204

Defer play_session bootstrap until the library index is ready with retry
while timeline mode is active; resolve history and queue rows by serverId
+ trackId for mixed-server queues; add tests for bootstrap defer and ref lookup.

* chore(queue): remove dead timeline scroll guard in QueueList

Timeline scroll is handled in the virtual-rows effect; the legacy branch
is queue/playlist only.

* fix(queue): address PR review nits for timeline play history

Use authoritative row.ref.serverId for history clicks before resolver fill;
simplify empty bootstrap seed; tighten completion types; unify recent_plays SQL.

* fix(queue): immutable session history append for useSyncExternalStore

Replace in-place push with a fresh array so getSnapshot returns a new
reference and React re-renders on live appends without a playerStore update.
This commit is contained in:
cucadmuh
2026-06-28 05:08:02 +03:00
committed by GitHub
parent 979eb85ad1
commit 281e86fd3b
44 changed files with 1416 additions and 169 deletions
+9
View File
@@ -330,6 +330,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Strengthens the existing disconnect/recovery path: connection status is now shared across all `useConnectionStatus` hook instances, so a successful **Retry** on the offline banner clears offline-browse sidebar filtering in step with the header connection indicator (no app restart). * Strengthens the existing disconnect/recovery path: connection status is now shared across all `useConnectionStatus` hook instances, so a successful **Retry** on the offline banner clears offline-browse sidebar filtering in step with the header connection indicator (no app restart).
### Timeline play history disappeared on album/playlist play
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1204](https://github.com/Psychotoxical/psysonic/pull/1204)**, closes [#1096](https://github.com/Psychotoxical/psysonic/issues/1096)
* Timeline mode now keeps a session play-history strip (plus cold bootstrap of the last 50 plays from statistics) when Play album/playlist replaces the queue; canonical queue sync is unchanged.
* The current track stays pinned to the top of the list; clicking a history row inserts after the playing track instead of replacing the queue, and replayed tracks remain in the history strip.
* History rows from other servers resolve album/cover metadata per server so Now Playing artwork loads when replaying cross-server plays.
* Cross-server queue switches now send `playbackReport` **stopped** to the previous server so its Who is listening entry clears promptly.
## Under the Hood ## Under the Hood
### ESLint setup and a strict lint pass over the frontend ### ESLint setup and a strict lint pass over the frontend
@@ -24,7 +24,7 @@ use crate::dto::{
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto, LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto, LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto, PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionRecentTrackDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
TrackArtifactDto, TrackFactDto, TrackRefDto, TrackArtifactDto, TrackFactDto, TrackRefDto,
}; };
use crate::live_search; use crate::live_search;
@@ -1234,6 +1234,16 @@ pub fn library_get_player_stats_recent_days(
PlaySessionRepository::new(&runtime.store).recent_days(limit.unwrap_or(30)) PlaySessionRepository::new(&runtime.store).recent_days(limit.unwrap_or(30))
} }
#[tauri::command]
pub fn library_get_recent_play_sessions(
runtime: State<'_, LibraryRuntime>,
limit: Option<u32>,
since_ms: Option<i64>,
) -> Result<Vec<PlaySessionRecentTrackDto>, String> {
PlaySessionRepository::new(&runtime.store)
.recent_plays(limit.unwrap_or(50), since_ms)
}
#[tauri::command] #[tauri::command]
pub fn library_purge_server( pub fn library_purge_server(
runtime: State<'_, LibraryRuntime>, runtime: State<'_, LibraryRuntime>,
@@ -346,6 +346,9 @@ pub struct PlaySessionDayTrackDto {
pub listened_sec: f64, pub listened_sec: f64,
pub completion: String, pub completion: String,
pub started_at_ms: i64, pub started_at_ms: i64,
pub album: Option<String>,
pub album_id: Option<String>,
pub cover_art_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -355,6 +358,9 @@ pub struct PlaySessionDayDetailDto {
pub tracks: Vec<PlaySessionDayTrackDto>, pub tracks: Vec<PlaySessionDayTrackDto>,
} }
/// One row from `library_get_recent_play_sessions` (timeline cold bootstrap).
pub type PlaySessionRecentTrackDto = PlaySessionDayTrackDto;
/// Summary for one day in the recent-days list (no track rows). /// Summary for one day in the recent-days list (no track rows).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -13,7 +13,7 @@ use rusqlite::{params, OptionalExtension};
use crate::dto::{ use crate::dto::{
PlaySessionDayDetailDto, PlaySessionDayTrackDto, PlaySessionDayTotalsDto, PlaySessionDayDetailDto, PlaySessionDayTrackDto, PlaySessionDayTotalsDto,
PlaySessionHeatmapDayDto, PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionHeatmapDayDto, PlaySessionInputDto, PlaySessionRecentDayDto,
PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PlaySessionRecentTrackDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto,
}; };
use crate::store::LibraryStore; use crate::store::LibraryStore;
@@ -22,6 +22,21 @@ use completion::{
completion_from_position, effective_duration_sec, MIN_LISTENED_SEC, completion_from_position, effective_duration_sec, MIN_LISTENED_SEC,
}; };
fn map_play_session_track_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PlaySessionDayTrackDto> {
Ok(PlaySessionDayTrackDto {
server_id: row.get(0)?,
track_id: row.get(1)?,
title: row.get(2)?,
artist: row.get(3)?,
listened_sec: row.get(4)?,
completion: row.get(5)?,
started_at_ms: row.get(6)?,
album: row.get(7)?,
album_id: row.get(8)?,
cover_art_id: row.get(9)?,
})
}
struct DayAgg { struct DayAgg {
total_listened_sec: f64, total_listened_sec: f64,
track_play_count: u32, track_play_count: u32,
@@ -223,24 +238,15 @@ impl<'a> PlaySessionRepository<'a> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT ps.server_id, ps.track_id, t.title, t.artist, \ "SELECT ps.server_id, ps.track_id, t.title, t.artist, \
ps.listened_sec, ps.completion, ps.started_at_ms \ ps.listened_sec, ps.completion, ps.started_at_ms, \
t.album, t.album_id, t.cover_art_id \
FROM play_session ps \ FROM play_session ps \
JOIN track t ON t.server_id = ps.server_id AND t.id = ps.track_id \ JOIN track t ON t.server_id = ps.server_id AND t.id = ps.track_id \
WHERE date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?1 \ WHERE date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?1 \
ORDER BY ps.started_at_ms DESC", ORDER BY ps.started_at_ms DESC",
)?; )?;
let tracks = stmt let tracks = stmt
.query_map(params![date_iso], |row| { .query_map(params![date_iso], map_play_session_track_row)?
Ok(PlaySessionDayTrackDto {
server_id: row.get(0)?,
track_id: row.get(1)?,
title: row.get(2)?,
artist: row.get(3)?,
listened_sec: row.get(4)?,
completion: row.get(5)?,
started_at_ms: row.get(6)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
let plays: Vec<PlaySpan> = tracks let plays: Vec<PlaySpan> = tracks
@@ -346,4 +352,29 @@ impl<'a> PlaySessionRepository<'a> {
}) })
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// Most recent track plays across all servers (newest first). Used for timeline cold bootstrap.
pub fn recent_plays(
&self,
limit: u32,
since_ms: Option<i64>,
) -> Result<Vec<PlaySessionRecentTrackDto>, String> {
let limit = limit.clamp(1, 200);
self.store
.with_read_conn(|conn| {
let sql = "SELECT ps.server_id, ps.track_id, t.title, t.artist, \
ps.listened_sec, ps.completion, ps.started_at_ms, \
t.album, t.album_id, t.cover_art_id \
FROM play_session ps \
INNER JOIN track t \
ON t.server_id = ps.server_id AND t.id = ps.track_id AND t.deleted = 0 \
WHERE (?2 IS NULL OR ps.started_at_ms >= ?2) \
ORDER BY ps.started_at_ms DESC \
LIMIT ?1";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map(params![limit, since_ms], map_play_session_track_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())
}
} }
@@ -424,3 +424,97 @@ fn purge_deletes_play_session_rows_for_server() {
assert_eq!(s1_count, 0); assert_eq!(s1_count, 0);
assert_eq!(s2_count, 1); assert_eq!(s2_count, 1);
} }
#[test]
fn recent_plays_returns_newest_first_and_respects_limit() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", 200);
seed_track(&store, "s1", "t2", 200);
seed_track(&store, "s2", "t3", 200);
let repo = PlaySessionRepository::new(&store);
for (sid, tid, ms) in [("s1", "t1", 1_000_i64), ("s1", "t2", 2_000), ("s2", "t3", 3_000)] {
repo.insert(&PlaySessionInputDto {
server_id: sid.into(),
track_id: tid.into(),
started_at_ms: ms,
listened_sec: 20.0,
position_max_sec: 15.0,
end_reason: "ended".into(),
duration_sec_hint: None,
})
.expect("insert");
}
let rows = repo.recent_plays(2, None).expect("recent");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].track_id, "t3");
assert_eq!(rows[1].track_id, "t2");
}
#[test]
fn recent_plays_excludes_deleted_tracks() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", 200);
let repo = PlaySessionRepository::new(&store);
repo.insert(&sample_input("s1", "t1")).expect("insert");
store
.with_conn_mut("test.soft_delete", |conn| {
conn.execute(
"UPDATE track SET deleted = 1 WHERE server_id = ?1 AND id = ?2",
rusqlite::params!["s1", "t1"],
)?;
Ok(())
})
.expect("soft delete");
let rows = repo.recent_plays(10, None).expect("recent");
assert!(rows.is_empty());
}
#[test]
fn recent_plays_includes_album_cover_metadata() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[TrackRow {
server_id: "s1".into(),
id: "t1".into(),
title: "Song".into(),
title_sort: None,
artist: Some("Artist".into()),
artist_id: None,
album: "Album Name".into(),
album_id: Some("al-1".into()),
album_artist: None,
duration_sec: 200,
track_number: None,
disc_number: None,
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: Some("al-1".into()),
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}])
.expect("seed track");
let repo = PlaySessionRepository::new(&store);
repo.insert(&sample_input("s1", "t1")).expect("insert");
let rows = repo.recent_plays(1, None).expect("recent");
assert_eq!(rows[0].album.as_deref(), Some("Album Name"));
assert_eq!(rows[0].album_id.as_deref(), Some("al-1"));
assert_eq!(rows[0].cover_art_id.as_deref(), Some("al-1"));
}
+1
View File
@@ -837,6 +837,7 @@ pub fn run() {
psysonic_library::commands::library_get_player_stats_day_detail, psysonic_library::commands::library_get_player_stats_day_detail,
psysonic_library::commands::library_get_player_stats_year_bounds, psysonic_library::commands::library_get_player_stats_year_bounds,
psysonic_library::commands::library_get_player_stats_recent_days, psysonic_library::commands::library_get_player_stats_recent_days,
psysonic_library::commands::library_get_recent_play_sessions,
psysonic_library::commands::library_purge_server, psysonic_library::commands::library_purge_server,
psysonic_library::commands::library_migrate_server_index_keys, psysonic_library::commands::library_migrate_server_index_keys,
psysonic_library::commands::library_delete_server_data, psysonic_library::commands::library_delete_server_data,
+29 -1
View File
@@ -709,7 +709,7 @@ export type PlaySessionDayTrack = {
title: string; title: string;
artist: string | null; artist: string | null;
listenedSec: number; listenedSec: number;
completion: 'partial' | 'full' | string; completion: 'partial' | 'full';
startedAtMs: number; startedAtMs: number;
}; };
@@ -840,6 +840,34 @@ export function libraryGetPlayerStatsRecentDays(limit = 30): Promise<PlaySession
return invoke<PlaySessionRecentDay[]>('library_get_player_stats_recent_days', { limit }); return invoke<PlaySessionRecentDay[]>('library_get_player_stats_recent_days', { limit });
} }
export type PlaySessionRecentTrack = {
serverId: string;
trackId: string;
title: string;
artist: string | null;
album: string | null;
albumId: string | null;
coverArtId: string | null;
startedAtMs: number;
listenedSec: number;
completion: 'partial' | 'full';
};
export function libraryGetRecentPlaySessions(args?: {
limit?: number;
sinceMs?: number;
}): Promise<PlaySessionRecentTrack[]> {
return invoke<PlaySessionRecentTrack[]>('library_get_recent_play_sessions', {
limit: args?.limit,
sinceMs: args?.sinceMs,
}).then(rows =>
rows.map(row => ({
...row,
serverId: mapServerIdFromIndexKey(row.serverId),
})),
);
}
// ── Event subscriptions ─────────────────────────────────────────────── // ── Event subscriptions ───────────────────────────────────────────────
export interface LibrarySyncProgressPayload { export interface LibrarySyncProgressPayload {
+10 -3
View File
@@ -55,6 +55,7 @@ beforeEach(() => {
onInvoke('audio_get_state', () => ({ playing: false })); onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined); onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('discord_update_presence', () => undefined); onInvoke('discord_update_presence', () => undefined);
onInvoke('library_get_recent_play_sessions', () => []);
}); });
describe('QueuePanel — render surface', () => { describe('QueuePanel — render surface', () => {
@@ -154,14 +155,20 @@ describe('QueuePanel — display mode', () => {
it('header mode-toggle button advances queueDisplayMode (default queue → timeline)', () => { it('header mode-toggle button advances queueDisplayMode (default queue → timeline)', () => {
seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() }); seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() });
const { container } = renderWithProviders(<QueuePanel />); const { container } = renderWithProviders(<QueuePanel />);
// The mode toggle is the first .queue-action-btn in the header (the
// collapse chevron is the second). The toggle's label names its target;
// from the default 'queue' that is the next mode in the cycle, "Timeline".
const toggle = container.querySelector<HTMLButtonElement>('.queue-header .queue-action-btn'); const toggle = container.querySelector<HTMLButtonElement>('.queue-header .queue-action-btn');
expect(toggle?.getAttribute('aria-label')).toBe('Timeline'); expect(toggle?.getAttribute('aria-label')).toBe('Timeline');
toggle!.click(); toggle!.click();
expect(useAuthStore.getState().queueDisplayMode).toBe('timeline'); expect(useAuthStore.getState().queueDisplayMode).toBe('timeline');
}); });
it('timeline mode: renders current + upcoming only (not played queue prefix)', () => {
const tracks = makeTracks(4);
useAuthStore.getState().setQueueDisplayMode('timeline');
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
const { container } = renderWithProviders(<QueuePanel />);
const idxs = [...container.querySelectorAll('[data-queue-idx]')].map(r => r.getAttribute('data-queue-idx'));
expect(idxs).toEqual(['1', '2', '3']);
});
}); });
describe('QueuePanel — toolbar', () => { describe('QueuePanel — toolbar', () => {
+20 -5
View File
@@ -33,6 +33,8 @@ import { QueueToolbar } from './queuePanel/QueueToolbar';
import { QueueList } from './queuePanel/QueueList'; import { QueueList } from './queuePanel/QueueList';
import { QueueTabBar } from './queuePanel/QueueTabBar'; import { QueueTabBar } from './queuePanel/QueueTabBar';
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll'; import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
import { useTimelineBootstrapOnMode, useTimelineHistoryResolver, useTimelinePlayHistory } from '../hooks/useTimelinePlayHistory';
import { buildTimelineDisplayRows } from '../utils/queue/buildTimelineDisplayRows';
import { activeServerQueueTrackIds } from '../utils/playback/trackServerScope'; import { activeServerQueueTrackIds } from '../utils/playback/trackServerScope';
export default function QueuePanel() { export default function QueuePanel() {
@@ -120,6 +122,17 @@ function QueuePanelHostOrSolo() {
const setIsNowPlayingCollapsed = useAuthStore(s => s.setQueueNowPlayingCollapsed); const setIsNowPlayingCollapsed = useAuthStore(s => s.setQueueNowPlayingCollapsed);
const queueDisplayMode = useAuthStore(s => s.queueDisplayMode); const queueDisplayMode = useAuthStore(s => s.queueDisplayMode);
const setQueueDisplayMode = useAuthStore(s => s.setQueueDisplayMode); const setQueueDisplayMode = useAuthStore(s => s.setQueueDisplayMode);
useTimelineBootstrapOnMode(queueDisplayMode === 'timeline');
const timelineHistoryRefs = useTimelinePlayHistory();
useTimelineHistoryResolver(timelineHistoryRefs, queueDisplayMode === 'timeline');
const timelineRows = useMemo(() => {
if (queueDisplayMode !== 'timeline') return undefined;
return buildTimelineDisplayRows({
historyRefs: timelineHistoryRefs,
queueItems,
queueIndex,
});
}, [queueDisplayMode, timelineHistoryRefs, queueItems, queueIndex]);
const toolbarButtons = useQueueToolbarStore(s => s.buttons); const toolbarButtons = useQueueToolbarStore(s => s.buttons);
const durationMode = useAuthStore(s => s.queueDurationDisplayMode); const durationMode = useAuthStore(s => s.queueDurationDisplayMode);
const setDurationMode = useAuthStore(s => s.setQueueDurationDisplayMode); const setDurationMode = useAuthStore(s => s.setQueueDurationDisplayMode);
@@ -221,11 +234,11 @@ function QueuePanelHostOrSolo() {
// index for every index-based handler (play / remove / reorder / drag). // index for every index-based handler (play / remove / reorder / drag).
const displayBaseIndex = queueDisplayMode === 'queue' ? Math.max(0, queueIndex + 1) : 0; const displayBaseIndex = queueDisplayMode === 'queue' ? Math.max(0, queueIndex + 1) : 0;
const displayItems = displayBaseIndex > 0 ? queueItems.slice(displayBaseIndex) : queueItems; const displayItems = displayBaseIndex > 0 ? queueItems.slice(displayBaseIndex) : queueItems;
// In queue mode the list can be empty while the queue still holds the const queueEmptyLabel = queueDisplayMode === 'timeline'
// now-playing (last) track — say "no upcoming" rather than "queue is empty". ? (timelineRows && timelineRows.length > 0 ? '' : t('queue.emptyQueue'))
const queueEmptyLabel = queueDisplayMode === 'queue' && queueItems.length > 0 : queueDisplayMode === 'queue' && queueItems.length > 0
? t('queue.noUpcoming') ? t('queue.noUpcoming')
: t('queue.emptyQueue'); : t('queue.emptyQueue');
return ( return (
<aside <aside
@@ -350,6 +363,8 @@ function QueuePanelHostOrSolo() {
<QueueList <QueueList
queue={displayItems} queue={displayItems}
timelineRows={timelineRows}
canonicalQueue={queueItems}
queueIndex={queueIndex} queueIndex={queueIndex}
displayBaseIndex={displayBaseIndex} displayBaseIndex={displayBaseIndex}
queueDisplayMode={queueDisplayMode} queueDisplayMode={queueDisplayMode}
+251 -121
View File
@@ -13,6 +13,10 @@ import {
getQueueResolverVersion, getQueueResolverVersion,
subscribeQueueResolver, subscribeQueueResolver,
} from '../../utils/library/queueTrackResolver'; } from '../../utils/library/queueTrackResolver';
import { findQueueItemRefIndex } from '../../utils/playback/queueIdentity';
import type { TimelineDisplayRow } from '../../utils/queue/buildTimelineDisplayRows';
import { findTimelineScrollLocalIndex } from '../../utils/queue/buildTimelineDisplayRows';
import { playTimelineHistoryTrack } from '../../utils/queue/playTimelineHistoryTrack';
type StartDrag = ( type StartDrag = (
payload: { data: string; label: string }, payload: { data: string; label: string },
@@ -21,18 +25,14 @@ type StartDrag = (
) => void; ) => void;
interface Props { interface Props {
/** The rows to render. In queue mode this is the upcoming-only slice of the
* canonical queue; in playlist mode it is the full queue. */
queue: QueueItemRef[]; queue: QueueItemRef[];
/** Absolute index of the currently playing track in the canonical queue. */ /** Timeline virtual rows; when set, `queue` is ignored for rendering. */
timelineRows?: TimelineDisplayRow[];
/** Canonical queue for history-row play / context-menu index lookup. */
canonicalQueue?: QueueItemRef[];
queueIndex: number; queueIndex: number;
/** Absolute index of `queue[0]` in the canonical queue (0 in playlist mode,
* `queueIndex + 1` in queue mode). Added to a row's local index to recover
* its absolute index for play / context-menu / drag / reorder. */
displayBaseIndex: number; displayBaseIndex: number;
queueDisplayMode: QueueDisplayMode; queueDisplayMode: QueueDisplayMode;
/** Label for the empty list (differs between "queue is empty" and "no
* upcoming tracks"). */
emptyLabel: string; emptyLabel: string;
contextMenu: PlayerState['contextMenu']; contextMenu: PlayerState['contextMenu'];
playTrack: PlayerState['playTrack']; playTrack: PlayerState['playTrack'];
@@ -48,80 +48,79 @@ interface Props {
t: TFunction; t: TFunction;
} }
// Stable reference so the virtualizer never sees a "changed" option on re-render
// (an inline object literal would be a new ref every render). Only used until the
// ResizeObserver reports the real viewport height.
const INITIAL_RECT = { width: 0, height: 600 }; const INITIAL_RECT = { width: 0, height: 600 };
export function QueueList({ export function QueueList({
queue, queueIndex, displayBaseIndex, queueDisplayMode, emptyLabel, queue, timelineRows, canonicalQueue, queueIndex, displayBaseIndex, queueDisplayMode, emptyLabel,
contextMenu, playTrack, activeTab, queueListRef, contextMenu, playTrack, activeTab, queueListRef,
suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget, suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget,
startDrag, orbitAttributionLabel, luckyRolling, t, startDrag, orbitAttributionLabel, luckyRolling, t,
}: Props) { }: Props) {
// Thin-state: the queue prop is the canonical `QueueItemRef[]`. Each row's
// full Track comes from the resolver (cache → placeholder; F4 overrides merged
// in resolveQueueTrack). Subscribe once so the list re-renders as the cache
// fills. Pure read in render — no cache mutation (the freeze landmine).
useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion); useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
// Virtualize so a 10k+ Artist-Radio queue keeps DOM at O(visible rows). const usingTimeline = queueDisplayMode === 'timeline' && timelineRows != null;
// Scroll element is the OverlayScrollArea viewport (`queueListRef`); rows have const rowCount = usingTimeline ? timelineRows.length : queue.length;
// variable height (radio/auto dividers, lucky-mix loader) so we measure them.
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
// eslint-disable-next-line react-hooks/incompatible-library // eslint-disable-next-line react-hooks/incompatible-library
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: queue.length, count: rowCount,
getScrollElement: () => queueListRef.current, getScrollElement: () => queueListRef.current,
estimateSize: () => 52, estimateSize: () => 52,
overscan: 10, overscan: 10,
getItemKey: i => `${queue[i].trackId}:${i}`, getItemKey: i => {
// Start with a sensible viewport height so rows render before the if (usingTimeline) return timelineRows[i]!.key;
// ResizeObserver reports the real size (SSR / jsdom, where the observer return `${queue[i].trackId}:${i}`;
// never fires). The real height overrides this on first measure. },
initialRect: INITIAL_RECT, initialRect: INITIAL_RECT,
}); });
const virtualItems = rowVirtualizer.getVirtualItems(); const virtualItems = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize(); const totalSize = rowVirtualizer.getTotalSize();
// Auto-scroll on track change (and on mode toggle). Honours the suppression
// flag (row click / undo restore). Two-step where it scrolls: the virtualizer
// brings the target row into the rendered range (estimate-based, can land a
// few px off), then scrollIntoView snaps it flush at the top (exact).
useEffect(() => { useEffect(() => {
if (suppressNextAutoScrollRef.current) { if (suppressNextAutoScrollRef.current) {
suppressNextAutoScrollRef.current = false; suppressNextAutoScrollRef.current = false;
return; return;
} }
if (activeTab !== 'queue' || queueIndex < 0 || queue.length === 0) return; if (activeTab !== 'queue' || rowCount === 0 || !usingTimeline || !timelineRows) return;
const pinToTop = (localIndex: number, absIndex: number) => { const localIdx = findTimelineScrollLocalIndex(timelineRows);
if (localIdx == null) return;
const pinToTop = (index: number, scrollSelector: string) => {
rowVirtualizer.scrollToIndex(index, { align: 'start' });
const id = requestAnimationFrame(() => {
const el = queueListRef.current?.querySelector<HTMLElement>(scrollSelector);
el?.scrollIntoView({ block: 'start', behavior: 'instant' });
});
return () => cancelAnimationFrame(id);
};
return pinToTop(localIdx, `[data-timeline-local-idx="${localIdx}"]`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queueIndex, activeTab, queueDisplayMode, usingTimeline]);
useEffect(() => {
if (suppressNextAutoScrollRef.current) {
suppressNextAutoScrollRef.current = false;
return;
}
if (activeTab !== 'queue' || rowCount === 0 || usingTimeline) return;
const pinToTop = (localIndex: number, scrollSelector: string) => {
rowVirtualizer.scrollToIndex(localIndex, { align: 'start' }); rowVirtualizer.scrollToIndex(localIndex, { align: 'start' });
const id = requestAnimationFrame(() => { const id = requestAnimationFrame(() => {
const el = queueListRef.current?.querySelector<HTMLElement>(`[data-queue-idx="${absIndex}"]`); const el = queueListRef.current?.querySelector<HTMLElement>(scrollSelector);
el?.scrollIntoView({ block: 'start', behavior: 'instant' }); el?.scrollIntoView({ block: 'start', behavior: 'instant' });
}); });
return () => cancelAnimationFrame(id); return () => cancelAnimationFrame(id);
}; };
if (queueDisplayMode === 'queue') { if (queueDisplayMode === 'queue') {
// Upcoming-only: the next track is the first row — keep it pinned at the if (queueIndex < 0) return;
// top. The played track already dropped out of the slice. return pinToTop(0, `[data-queue-idx="${displayBaseIndex}"]`);
return pinToTop(0, displayBaseIndex);
} }
if (queueDisplayMode === 'timeline') { if (queueIndex < 0) return;
// Anchor the current track in the middle — history above, up-next below.
rowVirtualizer.scrollToIndex(queueIndex, { align: 'center' });
const id = requestAnimationFrame(() => {
const el = queueListRef.current?.querySelector<HTMLElement>(`[data-queue-idx="${queueIndex}"]`);
el?.scrollIntoView({ block: 'center', behavior: 'instant' });
});
return () => cancelAnimationFrame(id);
}
// Playlist: lazy. Let the highlight wander while the now-playing row stays
// visible; only re-pin it to the top once it has scrolled out of view.
const viewport = queueListRef.current; const viewport = queueListRef.current;
if (viewport) { if (viewport) {
const rowEl = viewport.querySelector<HTMLElement>(`[data-queue-idx="${queueIndex}"]`); const rowEl = viewport.querySelector<HTMLElement>(`[data-queue-idx="${queueIndex}"]`);
@@ -129,39 +128,221 @@ export function QueueList({
const rowRect = rowEl.getBoundingClientRect(); const rowRect = rowEl.getBoundingClientRect();
const viewRect = viewport.getBoundingClientRect(); const viewRect = viewport.getBoundingClientRect();
const fullyVisible = rowRect.top >= viewRect.top && rowRect.bottom <= viewRect.bottom; const fullyVisible = rowRect.top >= viewRect.top && rowRect.bottom <= viewRect.bottom;
if (fullyVisible) return; // highlight just moved within view — don't yank if (fullyVisible) return;
} }
} }
return pinToTop(queueIndex, queueIndex); return pinToTop(queueIndex, `[data-queue-idx="${queueIndex}"]`);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [queueIndex, activeTab, queueDisplayMode]); }, [queueIndex, activeTab, queueDisplayMode, rowCount, usingTimeline]);
const playHistoryRow = (serverId: string, trackId: string) => {
suppressNextAutoScrollRef.current = true;
void playTimelineHistoryTrack(serverId, trackId, canonicalQueue);
};
const renderTrackRow = (args: {
track: ReturnType<typeof resolveQueueTrack>;
absIdx: number | null;
localIndex: number;
isPlaying: boolean;
isPast: boolean;
isHistory: boolean;
base?: QueueItemRef;
dragStyle: React.CSSProperties;
}) => {
const { track, absIdx, localIndex, isPlaying, isPast, isHistory, base, dragStyle } = args;
return (
<div
data-timeline-local-idx={localIndex}
{...(isHistory ? { 'data-timeline-kind': 'history' } : {})}
{...(absIdx != null ? { 'data-queue-idx': absIdx } : {})}
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === (absIdx != null ? 'queue-item' : 'song') && (absIdx != null ? contextMenu.queueIndex === absIdx : contextMenu.item === track) ? 'context-active' : ''}`}
onClick={() => {
if (isHistory) {
playHistoryRow(base?.serverId ?? track.serverId ?? '', track.id);
return;
}
if (absIdx == null) return;
suppressNextAutoScrollRef.current = true;
playTrack(track, undefined, undefined, undefined, absIdx);
}}
onContextMenu={(e) => {
e.preventDefault();
if (isHistory && absIdx == null) {
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'song');
return;
}
if (absIdx == null) return;
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', absIdx);
}}
onMouseDown={(e) => {
if (isHistory || absIdx == null) return;
if (e.button !== 0) return;
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDragFromIdxRef.current = absIdx;
startDrag({ data: JSON.stringify({ type: 'queue_reorder', index: absIdx }), label: track.title }, me.clientX, me.clientY);
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
style={{ ...(isPast && !isPlaying ? { opacity: 0.5 } : null), ...dragStyle }}
>
<div className="queue-item-info">
<div className="queue-item-title truncate" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{isPlaying && <Play size={10} fill="currentColor" style={{ flexShrink: 0 }} />}
<span className="truncate">{track.title}</span>
</div>
<div className="queue-item-artist truncate">{track.artist}</div>
{(() => {
const label = orbitAttributionLabel(track.id);
return label ? <div className="queue-item-attribution truncate">{label}</div> : null;
})()}
</div>
<div className="queue-item-duration">
{formatTrackTime(track.duration)}
</div>
</div>
);
};
return ( return (
<OverlayScrollArea <OverlayScrollArea
viewportRef={queueListRef} viewportRef={queueListRef}
className="queue-list-wrap" className="queue-list-wrap"
viewportClassName="queue-list" viewportClassName="queue-list"
measureDeps={[activeTab, queue.length, totalSize]} measureDeps={[activeTab, rowCount, totalSize]}
railInset="panel" railInset="panel"
viewportScrollBehaviorAuto={isQueueDrag} viewportScrollBehaviorAuto={isQueueDrag}
> >
{queue.length === 0 ? ( {rowCount === 0 ? (
<div className="queue-empty"> emptyLabel ? (
{emptyLabel} <div className="queue-empty">{emptyLabel}</div>
</div> ) : null
) : ( ) : (
<div style={{ height: totalSize, width: '100%', position: 'relative' }}> <div style={{ height: totalSize, width: '100%', position: 'relative' }}>
{virtualItems.map(vi => { {virtualItems.map(vi => {
const idx = vi.index; const idx = vi.index;
// Local index addresses `queue` (the displayed slice); the absolute
// index addresses the canonical queue and drives every handler that if (usingTimeline && timelineRows) {
// mutates / selects (play, context menu, drag, reorder, drop target). const row = timelineRows[idx]!;
if (row.kind === 'divider') {
return (
<div
key={row.key}
data-index={idx}
data-timeline-local-idx={row.localIndex}
ref={rowVirtualizer.measureElement}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
>
<div className="queue-divider" style={{ margin: '2px 0' }}>
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)' }}>
{t(row.labelKey)}
</span>
</div>
</div>
);
}
const base = row.kind === 'history'
? { serverId: row.ref.serverId, trackId: row.ref.trackId }
: row.ref;
const track = resolveQueueTrack(base);
const absIdx = row.kind === 'history'
? findQueueItemRefIndex(
canonicalQueue ?? usePlayerStore.getState().queueItems,
row.ref,
)
: row.queueIndex;
const isPlaying = row.kind === 'current';
const isPast = row.kind === 'history';
const prevRow = idx > 0 ? timelineRows[idx - 1] : null;
const isFirstAutoAdded = row.kind === 'upcoming' && row.ref.autoAdded
&& (prevRow?.kind !== 'upcoming' || !prevRow.ref.autoAdded);
const isFirstRadioAdded = row.kind === 'upcoming' && row.ref.radioAdded
&& (prevRow?.kind !== 'upcoming' || !prevRow.ref.radioAdded);
let dragStyle: React.CSSProperties = {};
if (row.kind !== 'history' && isQueueDrag && psyDragFromIdxRef.current === absIdx) {
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
} else if (row.kind !== 'history' && isQueueDrag && externalDropTarget?.idx === absIdx) {
dragStyle = externalDropTarget.before
? { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' }
: { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
}
return (
<div
key={row.key}
data-index={idx}
ref={rowVirtualizer.measureElement}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
>
{isFirstRadioAdded && (
<div className="queue-divider" style={{ margin: '2px 0' }}>
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.radioAdded')}</span>
</div>
)}
{isFirstAutoAdded && (
<div className="queue-divider" style={{ margin: '2px 0' }}>
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.autoAdded')}</span>
</div>
)}
{renderTrackRow({
track,
absIdx: row.kind === 'history' ? (absIdx >= 0 ? absIdx : null) : absIdx,
localIndex: row.localIndex,
isPlaying,
isPast,
isHistory: row.kind === 'history',
base,
dragStyle,
})}
{luckyRolling && isPlaying && (
<button
type="button"
className="queue-lucky-loading"
onClick={() => useLuckyMixStore.getState().cancel()}
data-tooltip={t('luckyMix.cancelTooltip')}
aria-label={t('luckyMix.cancelTooltip')}
>
<div className="queue-lucky-loading__dice">
<div className="queue-lucky-cube queue-lucky-cube--a">
<span className="lucky-mix-pip lucky-mix-pip--tl" />
<span className="lucky-mix-pip lucky-mix-pip--tr" />
<span className="lucky-mix-pip lucky-mix-pip--bl" />
<span className="lucky-mix-pip lucky-mix-pip--br" />
</div>
<div className="queue-lucky-cube queue-lucky-cube--b">
<span className="lucky-mix-pip lucky-mix-pip--center" />
</div>
<div className="queue-lucky-cube queue-lucky-cube--c">
<span className="lucky-mix-pip lucky-mix-pip--tl" />
<span className="lucky-mix-pip lucky-mix-pip--center" />
<span className="lucky-mix-pip lucky-mix-pip--br" />
</div>
</div>
</button>
)}
</div>
);
}
const absIdx = displayBaseIndex + idx; const absIdx = displayBaseIndex + idx;
const base = queue[idx]; const base = queue[idx];
const track = resolveQueueTrack(base); const track = resolveQueueTrack(base);
const isPlaying = absIdx === queueIndex; const isPlaying = absIdx === queueIndex;
const isTimeline = queueDisplayMode === 'timeline'; const isPast = false;
const isPast = isTimeline && absIdx < queueIndex;
const isFirstAutoAdded = base.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded); const isFirstAutoAdded = base.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
const isFirstRadioAdded = base.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded); const isFirstRadioAdded = base.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
@@ -183,16 +364,6 @@ export function QueueList({
ref={rowVirtualizer.measureElement} ref={rowVirtualizer.measureElement}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }} style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
> >
{isTimeline && idx === 0 && queueIndex > 0 && (
<div className="queue-divider" style={{ margin: '2px 0' }}>
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.history')}</span>
</div>
)}
{isTimeline && absIdx === queueIndex + 1 && (
<div className="queue-divider" style={{ margin: '2px 0' }}>
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.upNext')}</span>
</div>
)}
{isFirstRadioAdded && ( {isFirstRadioAdded && (
<div className="queue-divider" style={{ margin: '2px 0' }}> <div className="queue-divider" style={{ margin: '2px 0' }}>
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.radioAdded')}</span> <span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.radioAdded')}</span>
@@ -203,57 +374,16 @@ export function QueueList({
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.autoAdded')}</span> <span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.autoAdded')}</span>
</div> </div>
)} )}
<div {renderTrackRow({
data-queue-idx={absIdx} track,
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === absIdx ? 'context-active' : ''}`} absIdx,
onClick={() => { localIndex: idx,
suppressNextAutoScrollRef.current = true; isPlaying,
// Same-queue jump: undefined keeps the canonical refs; the row isPast,
// index lands a click on a duplicate track on *this* slot, not isHistory: false,
// the first occurrence (issue #500). base,
playTrack(track, undefined, undefined, undefined, absIdx); dragStyle,
}} })}
onContextMenu={(e) => {
e.preventDefault();
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', absIdx);
}}
onMouseDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDragFromIdxRef.current = absIdx;
startDrag({ data: JSON.stringify({ type: 'queue_reorder', index: absIdx }), label: track.title }, me.clientX, me.clientY);
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
style={{ ...(isPast && !isPlaying ? { opacity: 0.5 } : null), ...dragStyle }}
>
<div className="queue-item-info">
<div className="queue-item-title truncate" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{isPlaying && <Play size={10} fill="currentColor" style={{ flexShrink: 0 }} />}
<span className="truncate">{track.title}</span>
</div>
<div className="queue-item-artist truncate">{track.artist}</div>
{(() => {
const label = orbitAttributionLabel(track.id);
return label ? <div className="queue-item-attribution truncate">{label}</div> : null;
})()}
</div>
<div className="queue-item-duration">
{formatTrackTime(track.duration)}
</div>
</div>
{luckyRolling && isPlaying && ( {luckyRolling && isPlaying && (
<button <button
type="button" type="button"
+1
View File
@@ -177,6 +177,7 @@ const CONTRIBUTOR_ENTRIES = [
'Hi-Res transition blend rate — configurable 44.1/88.2/96 kHz resampling for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate (PR #1171)', 'Hi-Res transition blend rate — configurable 44.1/88.2/96 kHz resampling for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate (PR #1171)',
'AutoDJ overlap cap — Auto (12 s content cap) or Limit (230 s slider, default 15 s) in track-transition settings; Orbit sync + engine override up to 30 s (PR #1173)', 'AutoDJ overlap cap — Auto (12 s content cap) or Limit (230 s slider, default 15 s) in track-transition settings; Orbit sync + engine override up to 30 s (PR #1173)',
'Connection recovery — shared connection status across hook instances so manual Retry clears offline sidebar gating with the header indicator (PR #1190)', 'Connection recovery — shared connection status across hook instances so manual Retry clears offline sidebar gating with the header indicator (PR #1190)',
'Timeline play history — session buffer + play_session bootstrap across queue replace; pin current to top; history replay inserts in-place (PR #1204)',
], ],
}, },
{ {
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import {
_resetTimelineSessionHistoryForTest,
isTimelineBootstrapAttempted,
} from '../store/timelineSessionHistory';
import {
_resetTimelineBootstrapInFlightForTest,
ensureTimelineBootstrap,
} from './useTimelinePlayHistory';
vi.mock('../api/library', () => ({
libraryGetRecentPlaySessions: vi.fn(async () => []),
TIMELINE_HISTORY_BOOTSTRAP_LIMIT: 50,
}));
vi.mock('../utils/queue/timelineBootstrapReady', () => ({
timelineBootstrapIndexReady: vi.fn(),
}));
vi.mock('../utils/library/queueTrackResolver', async importOriginal => {
const actual = await importOriginal<typeof import('../utils/library/queueTrackResolver')>();
return { ...actual, seedQueueResolver: vi.fn() };
});
import { libraryGetRecentPlaySessions } from '../api/library';
import { timelineBootstrapIndexReady } from '../utils/queue/timelineBootstrapReady';
describe('ensureTimelineBootstrap', () => {
beforeEach(() => {
_resetTimelineSessionHistoryForTest();
_resetTimelineBootstrapInFlightForTest();
vi.mocked(timelineBootstrapIndexReady).mockReset();
vi.mocked(libraryGetRecentPlaySessions).mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it('defers without marking attempted when the index is not ready', async () => {
vi.mocked(timelineBootstrapIndexReady).mockResolvedValue(false);
await ensureTimelineBootstrap();
expect(isTimelineBootstrapAttempted()).toBe(false);
expect(libraryGetRecentPlaySessions).not.toHaveBeenCalled();
});
it('fetches once when the index is ready', async () => {
vi.mocked(timelineBootstrapIndexReady).mockResolvedValue(true);
await ensureTimelineBootstrap();
expect(isTimelineBootstrapAttempted()).toBe(true);
expect(libraryGetRecentPlaySessions).toHaveBeenCalledTimes(1);
});
it('does not fetch twice in the same session', async () => {
vi.mocked(timelineBootstrapIndexReady).mockResolvedValue(true);
await ensureTimelineBootstrap();
await ensureTimelineBootstrap();
expect(libraryGetRecentPlaySessions).toHaveBeenCalledTimes(1);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useMemo, useSyncExternalStore } from 'react';
import { libraryGetRecentPlaySessions, type PlaySessionRecentTrack } from '../api/library';
import { seedQueueResolver, resolveBatch } from '../utils/library/queueTrackResolver';
import {
applyTimelineBootstrap,
getTimelineSessionHistorySnapshot,
isTimelineBootstrapAttempted,
markTimelineBootstrapAttempted,
subscribeTimelineSessionHistory,
TIMELINE_HISTORY_BOOTSTRAP_LIMIT,
type TimelinePlayedRef,
} from '../store/timelineSessionHistory';
import {
bootstrapTrackFromPlaySession,
timelineHistoryToQueueRefs,
} from '../utils/queue/timelineHistoryRefs';
import { timelineBootstrapIndexReady } from '../utils/queue/timelineBootstrapReady';
const BOOTSTRAP_RETRY_MS = 2_000;
let bootstrapInFlight = false;
function bootstrapRowToRef(row: PlaySessionRecentTrack): TimelinePlayedRef {
return {
serverId: row.serverId,
trackId: row.trackId,
playedAtMs: row.startedAtMs,
};
}
function seedResolverFromBootstrap(rows: PlaySessionRecentTrack[]): void {
const byServer = new Map<string, ReturnType<typeof bootstrapTrackFromPlaySession>[]>();
for (const row of rows) {
const track = bootstrapTrackFromPlaySession(row);
const arr = byServer.get(row.serverId) ?? [];
arr.push(track);
byServer.set(row.serverId, arr);
}
for (const [serverId, tracks] of byServer) {
seedQueueResolver(serverId, tracks);
}
}
/** Test-only: reset in-flight bootstrap guard. */
export function _resetTimelineBootstrapInFlightForTest(): void {
bootstrapInFlight = false;
}
export async function ensureTimelineBootstrap(): Promise<void> {
if (isTimelineBootstrapAttempted() || bootstrapInFlight) return;
if (!(await timelineBootstrapIndexReady())) return;
bootstrapInFlight = true;
if (!markTimelineBootstrapAttempted()) {
bootstrapInFlight = false;
return;
}
try {
const rows = await libraryGetRecentPlaySessions({ limit: TIMELINE_HISTORY_BOOTSTRAP_LIMIT });
seedResolverFromBootstrap(rows);
const oldestFirst = [...rows].reverse().map(bootstrapRowToRef);
applyTimelineBootstrap(oldestFirst);
} catch {
/* bootstrapAttempted stays true — no retry until next app launch */
} finally {
bootstrapInFlight = false;
}
}
export function useTimelinePlayHistory(): TimelinePlayedRef[] {
return useSyncExternalStore(subscribeTimelineSessionHistory, getTimelineSessionHistorySnapshot);
}
export function useTimelineBootstrapOnMode(isTimeline: boolean): void {
useEffect(() => {
if (!isTimeline) return;
let cancelled = false;
const run = async () => {
while (!cancelled && !isTimelineBootstrapAttempted()) {
await ensureTimelineBootstrap();
if (cancelled || isTimelineBootstrapAttempted()) break;
await new Promise<void>(resolve => {
window.setTimeout(resolve, BOOTSTRAP_RETRY_MS);
});
}
};
void run();
return () => {
cancelled = true;
};
}, [isTimeline]);
}
/** Prefetch full track metadata (incl. cover ids) for cross-server history rows. */
export function useTimelineHistoryResolver(
historyRefs: TimelinePlayedRef[],
enabled: boolean,
): void {
const refsKey = useMemo(
() => historyRefs.map(r => `${r.serverId}:${r.trackId}:${r.playedAtMs}`).join('\u0001'),
[historyRefs],
);
useEffect(() => {
if (!enabled || historyRefs.length === 0) return;
void resolveBatch(timelineHistoryToQueueRefs(historyRefs));
}, [enabled, refsKey, historyRefs]);
}
+1 -1
View File
@@ -460,7 +460,7 @@ export const settings = {
queueModeTitle: 'Warteschlangen-Ansicht', queueModeTitle: 'Warteschlangen-Ansicht',
queueModeQueueSub: 'Zeigt nur kommende Titel. Der laufende Titel bleibt im Kopf und verschwindet aus der Liste, sobald er gespielt wurde.', queueModeQueueSub: 'Zeigt nur kommende Titel. Der laufende Titel bleibt im Kopf und verschwindet aus der Liste, sobald er gespielt wurde.',
queueModePlaylistSub: 'Behält die ganze Warteschlange in der Liste, der laufende Titel oben hervorgehoben; gespielte Titel bleiben stehen.', queueModePlaylistSub: 'Behält die ganze Warteschlange in der Liste, der laufende Titel oben hervorgehoben; gespielte Titel bleiben stehen.',
queueModeTimelineSub: 'Aktuellen Titel zentrieren — gespielter Verlauf darüber, kommende Titel (in Shuffle-Reihenfolge) darunter.', queueModeTimelineSub: 'Aktuellen Titel oben in der Liste halten — gespielter Verlauf darüber scrollbar, kommende Titel (in Shuffle-Reihenfolge) darunter.',
queueToolbarTitle: 'Warteschlangen-Toolbar', queueToolbarTitle: 'Warteschlangen-Toolbar',
queueToolbarReset: 'Zurücksetzen', queueToolbarReset: 'Zurücksetzen',
queueToolbarSeparator: 'Trennlinie', queueToolbarSeparator: 'Trennlinie',
+1 -1
View File
@@ -504,7 +504,7 @@ export const settings = {
queueModeTitle: 'Queue Display Mode', queueModeTitle: 'Queue Display Mode',
queueModeQueueSub: 'Show only upcoming tracks. The current track stays in the header and leaves the list once it has played.', queueModeQueueSub: 'Show only upcoming tracks. The current track stays in the header and leaves the list once it has played.',
queueModePlaylistSub: 'Keep the whole queue in the list with the current track highlighted at the top; played tracks stay.', queueModePlaylistSub: 'Keep the whole queue in the list with the current track highlighted at the top; played tracks stay.',
queueModeTimelineSub: 'Center the current track, with played history above and upcoming tracks (in shuffle order) below.', queueModeTimelineSub: 'Keep the current track at the top of the list, with played history scrollable above and upcoming tracks (in shuffle order) below.',
queueToolbarTitle: 'Queue Toolbar', queueToolbarTitle: 'Queue Toolbar',
queueToolbarReset: 'Reset to default', queueToolbarReset: 'Reset to default',
queueToolbarSeparator: 'Separator', queueToolbarSeparator: 'Separator',
+1 -1
View File
@@ -459,7 +459,7 @@ export const settings = {
queueModeTitle: 'Modo de visualización de la cola', queueModeTitle: 'Modo de visualización de la cola',
queueModeQueueSub: 'Muestra solo las próximas pistas. La pista actual permanece en el encabezado y sale de la lista al reproducirse.', queueModeQueueSub: 'Muestra solo las próximas pistas. La pista actual permanece en el encabezado y sale de la lista al reproducirse.',
queueModePlaylistSub: 'Mantiene toda la cola en la lista, con la pista actual resaltada arriba; las reproducidas permanecen.', queueModePlaylistSub: 'Mantiene toda la cola en la lista, con la pista actual resaltada arriba; las reproducidas permanecen.',
queueModeTimelineSub: 'Centrar la pista actual, con el historial reproducido arriba y las próximas pistas (en orden aleatorio) debajo.', queueModeTimelineSub: 'Mantener la pista actual arriba de la lista, con el historial reproducido desplazable arriba y las próximas pistas (en orden aleatorio) abajo.',
queueToolbarTitle: 'Barra de herramientas de cola', queueToolbarTitle: 'Barra de herramientas de cola',
queueToolbarReset: 'Restablecer a predeterminado', queueToolbarReset: 'Restablecer a predeterminado',
queueToolbarSeparator: 'Separador', queueToolbarSeparator: 'Separador',
+1 -1
View File
@@ -447,7 +447,7 @@ export const settings = {
queueModeTitle: 'Mode d\'affichage de la file', queueModeTitle: 'Mode d\'affichage de la file',
queueModeQueueSub: 'N\'affiche que les pistes à venir. La piste en cours reste dans l\'en-tête et quitte la liste une fois jouée.', queueModeQueueSub: 'N\'affiche que les pistes à venir. La piste en cours reste dans l\'en-tête et quitte la liste une fois jouée.',
queueModePlaylistSub: 'Conserve toute la file dans la liste, la piste en cours surlignée en haut ; les pistes jouées restent.', queueModePlaylistSub: 'Conserve toute la file dans la liste, la piste en cours surlignée en haut ; les pistes jouées restent.',
queueModeTimelineSub: 'Centrer le morceau en cours, avec l\'historique au-dessus et les morceaux à venir (dans l\'ordre aléatoire) en dessous.', queueModeTimelineSub: 'Garde le morceau en cours en haut de la liste, avec l\'historique défilable au-dessus et les morceaux à venir (dans l\'ordre aléatoire) en dessous.',
queueToolbarTitle: 'Barre d\'outils de file d\'attente', queueToolbarTitle: 'Barre d\'outils de file d\'attente',
queueToolbarReset: 'Réinitialiser', queueToolbarReset: 'Réinitialiser',
queueToolbarSeparator: 'Séparateur', queueToolbarSeparator: 'Séparateur',
+1 -1
View File
@@ -503,7 +503,7 @@ export const settings = {
queueModeTitle: 'Lejátszási sor megjelenítési módja', queueModeTitle: 'Lejátszási sor megjelenítési módja',
queueModeQueueSub: 'Csak a következő számokat mutatja. Az aktuális szám a fejlécben marad, és lejátszás után kikerül a listából.', queueModeQueueSub: 'Csak a következő számokat mutatja. Az aktuális szám a fejlécben marad, és lejátszás után kikerül a listából.',
queueModePlaylistSub: 'A teljes sort a listában tartja, az aktuális számmal felül kiemelve; a lejátszott számok megmaradnak.', queueModePlaylistSub: 'A teljes sort a listában tartja, az aktuális számmal felül kiemelve; a lejátszott számok megmaradnak.',
queueModeTimelineSub: 'Az aktuális számot középre helyezi, fölötte a lejátszott előzményekkel, alatta a következő számokkal (keverési sorrendben).', queueModeTimelineSub: 'Az aktuális számot a lista tetején tartja, fölötte görgethető lejátszási előzményekkel, alatta a következő számokkal (keverési sorrendben).',
queueToolbarTitle: 'Lejátszási sor eszköztára', queueToolbarTitle: 'Lejátszási sor eszköztára',
queueToolbarReset: 'Visszaállítás alapértelmezettre', queueToolbarReset: 'Visszaállítás alapértelmezettre',
queueToolbarSeparator: 'Elválasztó', queueToolbarSeparator: 'Elválasztó',
+1 -1
View File
@@ -497,7 +497,7 @@ export const settings = {
queueModeTitle: 'キュー表示モード', queueModeTitle: 'キュー表示モード',
queueModeQueueSub: '今後のトラックのみ表示します。現在のトラックはヘッダーに残り、再生済みになるとリストから外れます。', queueModeQueueSub: '今後のトラックのみ表示します。現在のトラックはヘッダーに残り、再生済みになるとリストから外れます。',
queueModePlaylistSub: 'キュー全体をリストに残し、現在のトラックを上部で強調表示します。再生済みトラックも残ります。', queueModePlaylistSub: 'キュー全体をリストに残し、現在のトラックを上部で強調表示します。再生済みトラックも残ります。',
queueModeTimelineSub: '現在のトラックを中央に置き、上に再生履歴、下に今後のトラック (シャッフル順) を表示します。', queueModeTimelineSub: '現在のトラックをリスト上部に固定し、上にスクロール可能な再生履歴、下に今後のトラック (シャッフル順) を表示します。',
queueToolbarTitle: 'キューツールバー', queueToolbarTitle: 'キューツールバー',
queueToolbarReset: '既定に戻す', queueToolbarReset: '既定に戻す',
queueToolbarSeparator: '区切り', queueToolbarSeparator: '区切り',
+1 -1
View File
@@ -446,7 +446,7 @@ export const settings = {
queueModeTitle: 'Visningsmodus for kø', queueModeTitle: 'Visningsmodus for kø',
queueModeQueueSub: 'Viser bare kommende spor. Det gjeldende sporet blir i toppen og forsvinner fra listen når det er spilt.', queueModeQueueSub: 'Viser bare kommende spor. Det gjeldende sporet blir i toppen og forsvinner fra listen når det er spilt.',
queueModePlaylistSub: 'Beholder hele køen i listen med gjeldende spor uthevet øverst; avspilte spor blir værende.', queueModePlaylistSub: 'Beholder hele køen i listen med gjeldende spor uthevet øverst; avspilte spor blir værende.',
queueModeTimelineSub: 'Sentrer gjeldende spor, med avspilt historikk over og kommende spor (i shuffle-rekkefølge) under.', queueModeTimelineSub: 'Hold gjeldende spor øverst i listen, med rullbar avspilt historikk over og kommende spor (i shuffle-rekkefølge) under.',
queueToolbarTitle: 'Kø-verktøylinje', queueToolbarTitle: 'Kø-verktøylinje',
queueToolbarReset: 'Tilbakestill til standard', queueToolbarReset: 'Tilbakestill til standard',
queueToolbarSeparator: 'Skilje', queueToolbarSeparator: 'Skilje',
+1 -1
View File
@@ -447,7 +447,7 @@ export const settings = {
queueModeTitle: 'Weergavemodus wachtrij', queueModeTitle: 'Weergavemodus wachtrij',
queueModeQueueSub: 'Toont alleen komende nummers. Het huidige nummer blijft in de kop en verdwijnt uit de lijst zodra het is afgespeeld.', queueModeQueueSub: 'Toont alleen komende nummers. Het huidige nummer blijft in de kop en verdwijnt uit de lijst zodra het is afgespeeld.',
queueModePlaylistSub: 'Houdt de hele wachtrij in de lijst met het huidige nummer bovenaan gemarkeerd; afgespeelde nummers blijven staan.', queueModePlaylistSub: 'Houdt de hele wachtrij in de lijst met het huidige nummer bovenaan gemarkeerd; afgespeelde nummers blijven staan.',
queueModeTimelineSub: 'Centreer de huidige track, met de afgespeelde geschiedenis erboven en de komende tracks (in shuffle-volgorde) eronder.', queueModeTimelineSub: 'Houd de huidige track bovenaan de lijst, met scrollbare afgespeelde geschiedenis erboven en komende tracks (in shuffle-volgorde) eronder.',
queueToolbarTitle: 'Wachtrij-werkbalk', queueToolbarTitle: 'Wachtrij-werkbalk',
queueToolbarReset: 'Standaard herstellen', queueToolbarReset: 'Standaard herstellen',
queueToolbarSeparator: 'Scheiding', queueToolbarSeparator: 'Scheiding',
+1 -1
View File
@@ -504,7 +504,7 @@ export const settings = {
queueModeTitle: 'Tryb wyświetlania kolejki', queueModeTitle: 'Tryb wyświetlania kolejki',
queueModeQueueSub: 'Pokaż tylko nadchodzące utwory. Bierzący utwór pozostaje w nagłówku i znika z listy po odtworzeniu.', queueModeQueueSub: 'Pokaż tylko nadchodzące utwory. Bierzący utwór pozostaje w nagłówku i znika z listy po odtworzeniu.',
queueModePlaylistSub: 'Zachowuj całą kolejkę na liście, z bieżącym utworem wyróżnionym u góry; odtworzone utwory pozostają na liście.', queueModePlaylistSub: 'Zachowuj całą kolejkę na liście, z bieżącym utworem wyróżnionym u góry; odtworzone utwory pozostają na liście.',
queueModeTimelineSub: 'Wyśrodkuj bieżący utwór, z historią odtworzeń powyżej i nadchodzącymi utworami poniżej (w kolejności losowej).', queueModeTimelineSub: 'Utrzymuj bieżący utwór u góry listy — historia odtworzeń przewijana powyżej, nadchodzące utwory (w kolejności losowej) poniżej.',
queueToolbarTitle: 'Pasek narzędzi kolejki', queueToolbarTitle: 'Pasek narzędzi kolejki',
queueToolbarReset: 'Przywróć domyślne', queueToolbarReset: 'Przywróć domyślne',
queueToolbarSeparator: 'Separator', queueToolbarSeparator: 'Separator',
+1 -1
View File
@@ -462,7 +462,7 @@ export const settings = {
queueModeTitle: 'Mod de afișare a cozii', queueModeTitle: 'Mod de afișare a cozii',
queueModeQueueSub: 'Arată doar piesele următoare. Piesa curentă rămâne în antet și iese din listă după ce a fost redată.', queueModeQueueSub: 'Arată doar piesele următoare. Piesa curentă rămâne în antet și iese din listă după ce a fost redată.',
queueModePlaylistSub: 'Păstrează toată coada în listă, cu piesa curentă evidențiată sus; piesele redate rămân.', queueModePlaylistSub: 'Păstrează toată coada în listă, cu piesa curentă evidențiată sus; piesele redate rămân.',
queueModeTimelineSub: 'Centrează piesa curentă, cu istoricul redat deasupra și piesele următoare (în ordine aleatorie) dedesubt.', queueModeTimelineSub: 'Păstrează piesa curentă sus în listă, cu istoricul redat derulabil deasupra și piesele următoare (în ordine aleatorie) dedesubt.',
queueToolbarTitle: 'Toolbar Coadă', queueToolbarTitle: 'Toolbar Coadă',
queueToolbarReset: 'Resetează la implicit', queueToolbarReset: 'Resetează la implicit',
queueToolbarSeparator: 'Separator', queueToolbarSeparator: 'Separator',
+1 -1
View File
@@ -517,7 +517,7 @@ export const settings = {
queueModeTitle: 'Режим отображения очереди', queueModeTitle: 'Режим отображения очереди',
queueModeQueueSub: 'Показывает только предстоящие треки. Текущий трек остаётся в заголовке и исчезает из списка после воспроизведения.', queueModeQueueSub: 'Показывает только предстоящие треки. Текущий трек остаётся в заголовке и исчезает из списка после воспроизведения.',
queueModePlaylistSub: 'Сохраняет всю очередь в списке, текущий трек выделен вверху; воспроизведённые треки остаются.', queueModePlaylistSub: 'Сохраняет всю очередь в списке, текущий трек выделен вверху; воспроизведённые треки остаются.',
queueModeTimelineSub: 'Центрировать текущий трек: история воспроизведения сверху, предстоящие треки (в порядке перемешивания) снизу.', queueModeTimelineSub: 'Текущий трек сверху списка; история воспроизведения прокручивается выше, предстоящие треки (в порядке перемешивания) — ниже.',
queueToolbarTitle: 'Панель инструментов очереди', queueToolbarTitle: 'Панель инструментов очереди',
queueToolbarReset: 'Сбросить', queueToolbarReset: 'Сбросить',
queueToolbarSeparator: 'Разделитель', queueToolbarSeparator: 'Разделитель',
+1 -1
View File
@@ -446,7 +446,7 @@ export const settings = {
queueModeTitle: '队列显示模式', queueModeTitle: '队列显示模式',
queueModeQueueSub: '仅显示即将播放的曲目。当前曲目保留在标题栏中,播放完毕后从列表移除。', queueModeQueueSub: '仅显示即将播放的曲目。当前曲目保留在标题栏中,播放完毕后从列表移除。',
queueModePlaylistSub: '在列表中保留整个队列,当前曲目在顶部高亮显示;已播放的曲目保留。', queueModePlaylistSub: '在列表中保留整个队列,当前曲目在顶部高亮显示;已播放的曲目保留。',
queueModeTimelineSub: '当前曲目居中显示,已播放历史在上方,即将播放的曲目(按随机顺序)在下方。', queueModeTimelineSub: '当前曲目保持在列表顶部,上方可滚动查看播放历史,下方为即将播放的曲目(按随机顺序)。',
queueToolbarTitle: '队列工具栏', queueToolbarTitle: '队列工具栏',
queueToolbarReset: '重置为默认', queueToolbarReset: '重置为默认',
queueToolbarSeparator: '分隔符', queueToolbarSeparator: '分隔符',
+14 -1
View File
@@ -16,6 +16,7 @@ import {
playListenSessionOnTrackSwitched, playListenSessionOnTrackSwitched,
playListenSessionOpen, playListenSessionOpen,
} from './playListenSession'; } from './playListenSession';
import { appendTimelineLeaveTrack } from './timelineSessionHistory';
import { getPerfProbeFlags } from '../utils/perf/perfFlags'; import { getPerfProbeFlags } from '../utils/perf/perfFlags';
import { bumpPerfCounter } from '../utils/perf/perfTelemetry'; import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
import { import {
@@ -477,6 +478,15 @@ export function handleAudioEnded(): void {
// opens a fresh session via playbackReportStart. // opens a fresh session via playbackReportStart.
void playbackReportStopped(); void playbackReportStopped();
const storeBeforeAdvance = usePlayerStore.getState();
if (storeBeforeAdvance.currentTrack && !storeBeforeAdvance.currentRadio) {
appendTimelineLeaveTrack(
storeBeforeAdvance.currentTrack,
storeBeforeAdvance.queueItems,
storeBeforeAdvance.queueIndex,
);
}
// Radio stream disconnected — just stop; don't advance queue. // Radio stream disconnected — just stop; don't advance queue.
if (usePlayerStore.getState().currentRadio) { if (usePlayerStore.getState().currentRadio) {
setIsAudioPaused(false); setIsAudioPaused(false);
@@ -531,7 +541,10 @@ export function handleAudioTrackSwitched(_duration: number): void {
if (store.currentTrack?.id) { if (store.currentTrack?.id) {
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id); useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
} }
const { queueItems, queueIndex, repeatMode } = store; const { queueItems, queueIndex, repeatMode, currentTrack, currentRadio } = store;
if (currentTrack && !currentRadio) {
appendTimelineLeaveTrack(currentTrack, queueItems, queueIndex);
}
const nextIdx = queueIndex + 1; const nextIdx = queueIndex + 1;
let nextTrack: Track | null = null; let nextTrack: Track | null = null;
let newIndex = queueIndex; let newIndex = queueIndex;
+30 -2
View File
@@ -1,4 +1,4 @@
import { playbackReportStart } from './playbackReportSession'; import { playbackReportStart, playbackReportStopped } from './playbackReportSession';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { getMusicNetworkRuntimeOrNull } from '../music-network'; import { getMusicNetworkRuntimeOrNull } from '../music-network';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate'; import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
@@ -66,6 +66,7 @@ import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import { pushQueueOnPlaybackStart } from './queueSync'; import { pushQueueOnPlaybackStart } from './queueSync';
import { playListenSessionFinalize } from './playListenSession'; import { playListenSessionFinalize } from './playListenSession';
import { pushQueueUndoFromGetter } from './queueUndo'; import { pushQueueUndoFromGetter } from './queueUndo';
import { appendTimelineLeaveTrack } from './timelineSessionHistory';
import { stopRadio } from './radioPlayer'; import { stopRadio } from './radioPlayer';
import { clearAllPlaybackScheduleTimers } from './scheduleTimers'; import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
import { clearSeekDebounce } from './seekDebounce'; import { clearSeekDebounce } from './seekDebounce';
@@ -184,7 +185,21 @@ export function runPlayTrack(
void playListenSessionFinalize('skip'); void playListenSessionFinalize('skip');
const scopedTrack = stampTrackServerId(track); const stateBeforeLeave = get();
const prevTrackForHistory = stateBeforeLeave.currentTrack;
const scopedTrackEarly = stampTrackServerId(track);
if (
prevTrackForHistory
&& !sameQueueTrackId(prevTrackForHistory.id, scopedTrackEarly.id)
) {
appendTimelineLeaveTrack(
prevTrackForHistory,
stateBeforeLeave.queueItems,
stateBeforeLeave.queueIndex,
);
}
const scopedTrack = scopedTrackEarly;
const scopedQueue = queue ? stampTrackServerIds(queue) : queue; const scopedQueue = queue ? stampTrackServerIds(queue) : queue;
clearAllPlaybackScheduleTimers(); clearAllPlaybackScheduleTimers();
@@ -247,6 +262,19 @@ export function runPlayTrack(
const playIdx = idx >= 0 ? idx : 0; const playIdx = idx >= 0 ? idx : 0;
const playingRef = replacing ? undefined : state.queueItems[playIdx]; const playingRef = replacing ? undefined : state.queueItems[playIdx];
const prevPlayingRef = replacing ? undefined : state.queueItems[state.queueIndex]; const prevPlayingRef = replacing ? undefined : state.queueItems[state.queueIndex];
const prevPlaybackSid = prevTrack && prevPlayingRef
? playbackProfileIdForTrack(prevTrack, prevPlayingRef) ?? ''
: '';
const nextPlaybackSid = playbackProfileIdForTrack(scopedTrack, playingRef) ?? '';
if (
prevTrack
&& !sameQueueTrackId(prevTrack.id, scopedTrack.id)
&& prevPlaybackSid
&& nextPlaybackSid
&& prevPlaybackSid !== nextPlaybackSid
) {
void playbackReportStopped(skipFromTimeSec);
}
// ±1 neighbours for replaygain normalization — resolve only these (not the // ±1 neighbours for replaygain normalization — resolve only these (not the
// whole queue). On replace they come from the provided Track[]; on navigation // whole queue). On replace they come from the provided Track[]; on navigation
// from the resolver cache (the bridge keeps that window warm). // from the resolver cache (the bridge keeps that window warm).
+19
View File
@@ -90,6 +90,25 @@ describe('playbackReportStart', () => {
expect(reportPlaybackMock).not.toHaveBeenCalled(); expect(reportPlaybackMock).not.toHaveBeenCalled();
expect(reportNowPlayingMock).toHaveBeenCalledWith('t1', SID); expect(reportNowPlayingMock).toHaveBeenCalledWith('t1', SID);
}); });
it('stops the previous server before opening a cross-server session', async () => {
playbackReportStart('t1', SID);
await flush();
reportPlaybackMock.mockClear();
playbackReportStart('t2', 'srv-2');
expect(reportPlaybackMock).toHaveBeenCalledTimes(1);
expect(reportPlaybackMock.mock.calls[0][1]).toMatchObject({
mediaId: 't1',
state: 'stopped',
});
await flush();
expect(reportPlaybackMock.mock.calls[1][1]).toMatchObject({
mediaId: 't2',
state: 'starting',
});
expect(reportPlaybackMock.mock.calls[2][1].state).toBe('playing');
});
}); });
describe('FSM transitions on an open session', () => { describe('FSM transitions on an open session', () => {
+37 -9
View File
@@ -65,6 +65,24 @@ function send(
}); });
} }
function stopExtensionSession(prev: ReportSession, explicitSec?: number): Promise<void> {
if (!extensionActive(prev.serverId)) return Promise.resolve();
return send(prev.serverId, prev.trackId, 'stopped', explicitSec);
}
function openExtensionSession(
serverId: string,
trackId: string,
isNewSession: boolean,
): void {
session = { serverId, trackId };
if (isNewSession) {
void send(serverId, trackId, 'starting').then(() => send(serverId, trackId, 'playing'));
} else {
void send(serverId, trackId, 'playing');
}
}
/** /**
* Track start / gapless switch / queue restore. Replaces the direct * Track start / gapless switch / queue restore. Replaces the direct
* `reportNowPlaying` presence call at those sites: the extension path opens the * `reportNowPlaying` presence call at those sites: the extension path opens the
@@ -72,17 +90,27 @@ function send(
*/ */
export function playbackReportStart(trackId: string, serverId: string): void { export function playbackReportStart(trackId: string, serverId: string): void {
if (!serverId || !nowPlayingEnabled()) return; if (!serverId || !nowPlayingEnabled()) return;
if (!extensionActive(serverId)) {
void reportNowPlaying(trackId, serverId); const prev = session;
const isNewSession = !prev || prev.trackId !== trackId || prev.serverId !== serverId;
const serverChanged = prev != null && prev.serverId !== serverId;
const openNext = () => {
if (!extensionActive(serverId)) {
session = { serverId, trackId };
void reportNowPlaying(trackId, serverId);
return;
}
openExtensionSession(serverId, trackId, isNewSession);
};
if (serverChanged) {
session = null;
void stopExtensionSession(prev).then(openNext);
return; return;
} }
const isNewSession = !session || session.trackId !== trackId || session.serverId !== serverId;
session = { serverId, trackId }; openNext();
if (isNewSession) {
void send(serverId, trackId, 'starting').then(() => send(serverId, trackId, 'playing'));
} else {
void send(serverId, trackId, 'playing');
}
} }
/** Engine-confirmed playback / resume / heartbeat (extension path only). */ /** Engine-confirmed playback / resume / heartbeat (extension path only). */
+11
View File
@@ -31,6 +31,10 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
})); }));
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
import {
appendTimelineSessionPlay,
getTimelineSessionHistorySnapshot,
} from './timelineSessionHistory';
import { onInvoke } from '@/test/mocks/tauri'; import { onInvoke } from '@/test/mocks/tauri';
import { resetPlayerStore } from '@/test/helpers/storeReset'; import { resetPlayerStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories'; import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
@@ -181,6 +185,13 @@ describe('clearQueue', () => {
usePlayerStore.getState().clearQueue(); usePlayerStore.getState().clearQueue();
expect(stop).toHaveBeenCalled(); expect(stop).toHaveBeenCalled();
}); });
it('clears timeline session history', () => {
appendTimelineSessionPlay({ serverId: 's1', trackId: 'a', playedAtMs: 1 });
seedQueue(makeTracks(2), { index: 0 });
usePlayerStore.getState().clearQueue();
expect(getTimelineSessionHistorySnapshot()).toEqual([]);
});
}); });
describe('reorderQueue', () => { describe('reorderQueue', () => {
+2
View File
@@ -24,6 +24,7 @@ import {
clearQueueServerForPlayback, clearQueueServerForPlayback,
ensureQueueServerPinned, ensureQueueServerPinned,
} from '../utils/playback/playbackServer'; } from '../utils/playback/playbackServer';
import { clearTimelineSessionHistory } from './timelineSessionHistory';
type SetState = ( type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>), partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
@@ -227,6 +228,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
clearSeekDebounce(); clearSeekTarget(); clearSeekDebounce(); clearSeekTarget();
clearRadioSessionSeenIds(); clearRadioSessionSeenIds();
setCurrentRadioArtistId(null); setCurrentRadioArtistId(null);
clearTimelineSessionHistory();
clearQueueServerForPlayback(); clearQueueServerForPlayback();
set({ queueItems: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); set({ queueItems: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
syncUserQueueMutationToServer([], null, 0); syncUserQueueMutationToServer([], null, 0);
@@ -0,0 +1,34 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { usePlayerStore } from './playerStore';
import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTracks, seedQueue } from '@/test/helpers/factories';
import { getTimelineSessionHistorySnapshot } from './timelineSessionHistory';
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
import { useAuthStore } from './authStore';
describe('timeline history on queue replace', () => {
beforeEach(() => {
resetAllStores();
const id = useAuthStore.getState().addServer({
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
});
useAuthStore.getState().setActiveServer(id);
registerDefaultCoverInvokeHandlers();
onInvoke('audio_play', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('discord_update_presence', () => undefined);
});
it('keeps session history when playTrack replaces the queue', () => {
const first = makeTracks(1);
seedQueue(first, { index: 0, currentTrack: first[0] });
const album = makeTracks(3);
usePlayerStore.getState().playTrack(album[0]!, album, true, true);
const history = getTimelineSessionHistorySnapshot();
expect(history.some(h => h.trackId === first[0]!.id)).toBe(true);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(album.map(t => t.id));
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
_resetTimelineSessionHistoryForTest,
appendTimelineSessionPlay,
applyTimelineBootstrap,
clearTimelineSessionHistory,
getTimelineSessionHistorySnapshot,
isTimelineBootstrapAttempted,
isTimelineHistoryClearedThisSession,
markTimelineBootstrapAttempted,
TIMELINE_APPEND_DEDUPE_MS,
TIMELINE_MERGE_DEDUPE_MS,
} from './timelineSessionHistory';
describe('timelineSessionHistory', () => {
beforeEach(() => {
_resetTimelineSessionHistoryForTest();
});
it('appends and dedupes within append window', () => {
const t0 = 1_000_000;
vi.spyOn(Date, 'now').mockReturnValue(t0);
appendTimelineSessionPlay({ serverId: 's1', trackId: 'a', playedAtMs: t0 });
appendTimelineSessionPlay({ serverId: 's1', trackId: 'a', playedAtMs: t0 + 500 });
expect(getTimelineSessionHistorySnapshot()).toHaveLength(1);
appendTimelineSessionPlay({ serverId: 's1', trackId: 'a', playedAtMs: t0 + TIMELINE_APPEND_DEDUPE_MS + 1 });
expect(getTimelineSessionHistorySnapshot()).toHaveLength(2);
vi.restoreAllMocks();
});
it('seeds bootstrap when buffer empty', () => {
applyTimelineBootstrap([
{ serverId: 's1', trackId: 'a', playedAtMs: 100 },
{ serverId: 's1', trackId: 'b', playedAtMs: 200 },
]);
expect(getTimelineSessionHistorySnapshot()).toEqual([
{ serverId: 's1', trackId: 'a', playedAtMs: 100 },
{ serverId: 's1', trackId: 'b', playedAtMs: 200 },
]);
});
it('merge prepends older bootstrap rows before live appends', () => {
appendTimelineSessionPlay({ serverId: 's1', trackId: 'live', playedAtMs: 5_000 });
applyTimelineBootstrap([
{ serverId: 's1', trackId: 'old', playedAtMs: 1_000 },
{ serverId: 's1', trackId: 'live', playedAtMs: 5_000 - TIMELINE_MERGE_DEDUPE_MS },
{ serverId: 's1', trackId: 'mid', playedAtMs: 3_000 },
]);
expect(getTimelineSessionHistorySnapshot().map(r => r.trackId)).toEqual(['old', 'mid', 'live']);
});
it('clear blocks bootstrap merge', () => {
clearTimelineSessionHistory();
expect(isTimelineHistoryClearedThisSession()).toBe(true);
applyTimelineBootstrap([{ serverId: 's1', trackId: 'a', playedAtMs: 1 }]);
expect(getTimelineSessionHistorySnapshot()).toHaveLength(0);
});
it('bootstrap attempted only once', () => {
expect(markTimelineBootstrapAttempted()).toBe(true);
expect(markTimelineBootstrapAttempted()).toBe(false);
expect(isTimelineBootstrapAttempted()).toBe(true);
});
});
+140
View File
@@ -0,0 +1,140 @@
import type { QueueItemRef, Track } from './playerStoreTypes';
import {
getPlaybackServerId,
playbackProfileIdForTrack,
} from '../utils/playback/playbackServer';
import { usePreviewStore } from './previewStore';
import { usePlayerStore } from './playerStore';
export const TIMELINE_HISTORY_BOOTSTRAP_LIMIT = 50;
export const TIMELINE_APPEND_DEDUPE_MS = 2_000;
export const TIMELINE_MERGE_DEDUPE_MS = 5_000;
export type TimelinePlayedRef = {
serverId: string;
trackId: string;
playedAtMs: number;
};
let sessionPlays: TimelinePlayedRef[] = [];
let historyClearedThisSession = false;
let bootstrapAttempted = false;
/** Stable reference for `useSyncExternalStore` until the next `emit`. */
let sessionPlaysSnapshot: TimelinePlayedRef[] = sessionPlays;
const listeners = new Set<() => void>();
function emit(): void {
sessionPlaysSnapshot = sessionPlays;
for (const cb of listeners) cb();
}
export function subscribeTimelineSessionHistory(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
export function getTimelineSessionHistorySnapshot(): TimelinePlayedRef[] {
return sessionPlaysSnapshot;
}
export function isTimelineHistoryClearedThisSession(): boolean {
return historyClearedThisSession;
}
export function isTimelineBootstrapAttempted(): boolean {
return bootstrapAttempted;
}
/** Returns false if bootstrap was already started this session. */
export function markTimelineBootstrapAttempted(): boolean {
if (bootstrapAttempted) return false;
bootstrapAttempted = true;
return true;
}
function isDuplicateInBuffer(
buffer: TimelinePlayedRef[],
candidate: TimelinePlayedRef,
windowMs: number,
): boolean {
return buffer.some(
row =>
row.serverId === candidate.serverId
&& row.trackId === candidate.trackId
&& Math.abs(row.playedAtMs - candidate.playedAtMs) <= windowMs,
);
}
export function appendTimelineSessionPlay(ref: TimelinePlayedRef): void {
if (!ref.serverId || !ref.trackId) return;
const last = sessionPlays[sessionPlays.length - 1];
if (
last
&& last.serverId === ref.serverId
&& last.trackId === ref.trackId
&& Math.abs(ref.playedAtMs - last.playedAtMs) <= TIMELINE_APPEND_DEDUPE_MS
) {
return;
}
sessionPlays = [...sessionPlays, ref];
emit();
}
export function appendTimelineLeaveTrack(
prevTrack: Track | null,
queueItems: QueueItemRef[],
queueIndex: number,
): void {
if (!prevTrack) return;
if (usePlayerStore.getState().currentRadio) return;
if (usePreviewStore.getState().previewingId) return;
const prevRef = queueIndex >= 0 && queueIndex < queueItems.length
? queueItems[queueIndex]
: undefined;
const serverId =
playbackProfileIdForTrack(prevTrack, prevRef)
?? getPlaybackServerId()
?? prevRef?.serverId
?? prevTrack.serverId
?? '';
appendTimelineSessionPlay({
serverId,
trackId: prevTrack.id,
playedAtMs: Date.now(),
});
}
export function clearTimelineSessionHistory(): void {
historyClearedThisSession = true;
sessionPlays = [];
emit();
}
export function applyTimelineBootstrap(rowsOldestFirst: TimelinePlayedRef[]): void {
if (historyClearedThisSession || rowsOldestFirst.length === 0) return;
if (sessionPlays.length === 0) {
sessionPlays = [...rowsOldestFirst];
emit();
return;
}
const firstLiveMs = sessionPlays[0]!.playedAtMs;
const toPrepend = rowsOldestFirst.filter(row => row.playedAtMs < firstLiveMs);
const deduped = toPrepend.filter(
row => !isDuplicateInBuffer(sessionPlays, row, TIMELINE_MERGE_DEDUPE_MS),
);
if (deduped.length === 0) return;
sessionPlays = [...deduped, ...sessionPlays];
emit();
}
/** Test-only reset */
export function _resetTimelineSessionHistoryForTest(): void {
sessionPlays = [];
sessionPlaysSnapshot = sessionPlays;
historyClearedThisSession = false;
bootstrapAttempted = false;
listeners.clear();
}
+2
View File
@@ -21,6 +21,7 @@
* beforeEach(resetAllStores); * beforeEach(resetAllStores);
*/ */
import { _resetQueueUndoStacksForTest } from '@/store/queueUndo'; import { _resetQueueUndoStacksForTest } from '@/store/queueUndo';
import { _resetTimelineSessionHistoryForTest } from '@/store/timelineSessionHistory';
import { usePlayerStore } from '@/store/playerStore'; import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { usePreviewStore } from '@/store/previewStore'; import { usePreviewStore } from '@/store/previewStore';
@@ -35,6 +36,7 @@ export function resetPlayerStore(): void {
usePlayerStore.setState(INITIAL_PLAYER_STATE, true); usePlayerStore.setState(INITIAL_PLAYER_STATE, true);
// Module-scoped queue undo/redo stacks live outside the Zustand state. // Module-scoped queue undo/redo stacks live outside the Zustand state.
_resetQueueUndoStacksForTest(); _resetQueueUndoStacksForTest();
_resetTimelineSessionHistoryForTest();
} }
export function resetAuthStore(): void { export function resetAuthStore(): void {
+17 -1
View File
@@ -1,4 +1,5 @@
import type { Track } from '../../store/playerStoreTypes'; import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
/** /**
* Strip the `stream:` prefix that some Rust events attach to track ids when * Strip the `stream:` prefix that some Rust events attach to track ids when
* they're routed through the HTTP source. Both forms identify the same track, * they're routed through the HTTP source. Both forms identify the same track,
@@ -18,6 +19,21 @@ export function sameQueueTrackId(a: string | undefined | null, b: string | undef
return na === nb; return na === nb;
} }
/** Canonical queue ref identity — server + track id (mixed-server safe). */
export function sameQueueItemRef(
a: Pick<QueueItemRef, 'serverId' | 'trackId'>,
b: Pick<QueueItemRef, 'serverId' | 'trackId'>,
): boolean {
return a.serverId === b.serverId && sameQueueTrackId(a.trackId, b.trackId);
}
export function findQueueItemRefIndex(
items: QueueItemRef[],
ref: Pick<QueueItemRef, 'serverId' | 'trackId'>,
): number {
return items.findIndex(r => sameQueueItemRef(r, ref));
}
/** /**
* Same-length + same-ids check. Used to skip no-op queue rewrites that would * Same-length + same-ids check. Used to skip no-op queue rewrites that would
* otherwise reset selection / scroll / drag-source state in subscribers. * otherwise reset selection / scroll / drag-source state in subscribers.
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { buildTimelineDisplayRows, findTimelineScrollLocalIndex } from './buildTimelineDisplayRows';
import type { QueueItemRef } from '../../store/playerStoreTypes';
const ref = (trackId: string, extra?: Partial<QueueItemRef>): QueueItemRef => ({
serverId: 's1',
trackId,
...extra,
});
describe('buildTimelineDisplayRows', () => {
it('orders history, current, and upcoming', () => {
const rows = buildTimelineDisplayRows({
historyRefs: [{ serverId: 's1', trackId: 'h1', playedAtMs: 1 }],
queueItems: [ref('c'), ref('u1'), ref('u2')],
queueIndex: 0,
});
expect(rows.map(r => r.kind)).toEqual([
'divider', 'history', 'current', 'divider', 'upcoming', 'upcoming',
]);
});
it('finds current row local index for scroll', () => {
const rows = buildTimelineDisplayRows({
historyRefs: [{ serverId: 's1', trackId: 'h1', playedAtMs: 1 }],
queueItems: [ref('c'), ref('u1')],
queueIndex: 0,
});
expect(findTimelineScrollLocalIndex(rows)).toBe(2);
});
});
@@ -0,0 +1,80 @@
import type { QueueItemRef } from '../../store/playerStoreTypes';
import type { TimelinePlayedRef } from '../../store/timelineSessionHistory';
export type TimelineDisplayRow =
| { kind: 'history'; ref: TimelinePlayedRef; localIndex: number; key: string }
| { kind: 'divider'; labelKey: 'queue.history' | 'queue.upNext'; localIndex: number; key: string }
| { kind: 'current'; ref: QueueItemRef; queueIndex: number; localIndex: number; key: string }
| { kind: 'upcoming'; ref: QueueItemRef; queueIndex: number; localIndex: number; key: string };
export function buildTimelineDisplayRows(args: {
historyRefs: TimelinePlayedRef[];
queueItems: QueueItemRef[];
queueIndex: number;
}): TimelineDisplayRow[] {
const { historyRefs, queueItems, queueIndex } = args;
const rows: TimelineDisplayRow[] = [];
let localIndex = 0;
if (historyRefs.length > 0) {
rows.push({
kind: 'divider',
labelKey: 'queue.history',
localIndex: localIndex++,
key: 'divider-history',
});
for (const ref of historyRefs) {
rows.push({
kind: 'history',
ref,
localIndex: localIndex++,
key: `history:${ref.serverId}:${ref.trackId}:${ref.playedAtMs}`,
});
}
}
const currentRef = queueIndex >= 0 && queueIndex < queueItems.length
? queueItems[queueIndex]
: null;
if (currentRef) {
rows.push({
kind: 'current',
ref: currentRef,
queueIndex,
localIndex: localIndex++,
key: `current:${currentRef.serverId}:${currentRef.trackId}:${queueIndex}`,
});
}
const upcoming = queueIndex >= 0
? queueItems.slice(queueIndex + 1)
: queueItems;
if (upcoming.length > 0) {
rows.push({
kind: 'divider',
labelKey: 'queue.upNext',
localIndex: localIndex++,
key: 'divider-upnext',
});
for (let i = 0; i < upcoming.length; i++) {
const absIdx = queueIndex >= 0 ? queueIndex + 1 + i : i;
const ref = upcoming[i]!;
rows.push({
kind: 'upcoming',
ref,
queueIndex: absIdx,
localIndex: localIndex++,
key: `upcoming:${ref.serverId}:${ref.trackId}:${absIdx}`,
});
}
}
return rows;
}
export function findTimelineScrollLocalIndex(rows: TimelineDisplayRow[]): number | null {
const current = rows.find(r => r.kind === 'current');
if (current) return current.localIndex;
const firstUpcoming = rows.find(r => r.kind === 'upcoming');
return firstUpcoming?.localIndex ?? null;
}
@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { usePlayerStore } from '../../store/playerStore';
import { resetPlayerStore } from '@/test/helpers/storeReset';
import { makeTrack, seedQueue } from '@/test/helpers/factories';
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
import { playTimelineHistoryTrack } from './playTimelineHistoryTrack';
vi.mock('@/utils/library/queueTrackResolver', async importOriginal => {
const actual = await importOriginal<typeof import('@/utils/library/queueTrackResolver')>();
return {
...actual,
resolveBatch: vi.fn(async () => undefined),
};
});
describe('playTimelineHistoryTrack', () => {
beforeEach(() => {
resetPlayerStore();
});
it('inserts after current when the track is not in the queue', async () => {
const a = makeTrack({ id: 'a' });
const b = makeTrack({ id: 'b' });
const c = makeTrack({ id: 'c' });
const h1 = makeTrack({ id: 'h1' });
seedQueue([a, b, c], { index: 1, currentTrack: b, serverId: 's1' });
seedQueueResolver('s1', [h1]);
const playTrack = vi.fn();
usePlayerStore.setState({ playTrack });
await playTimelineHistoryTrack('s1', 'h1');
expect(playTrack).toHaveBeenCalledTimes(1);
const [track, queue, , , targetIdx] = playTrack.mock.calls[0]!;
expect(track.id).toBe('h1');
expect(queue?.map((t: { id: string }) => t.id)).toEqual(['a', 'b', 'h1', 'c']);
expect(targetIdx).toBe(2);
});
it('jumps to an upcoming slot when the track is already queued ahead', async () => {
const a = makeTrack({ id: 'a' });
const b = makeTrack({ id: 'b' });
const c = makeTrack({ id: 'c' });
seedQueue([a, b, c], { index: 0, currentTrack: a, serverId: 's1' });
const playTrack = vi.fn();
usePlayerStore.setState({ playTrack });
await playTimelineHistoryTrack('s1', 'c');
expect(playTrack).toHaveBeenCalledWith(
expect.objectContaining({ id: 'c' }),
undefined,
undefined,
undefined,
2,
);
});
it('does not replace the queue when replaying a track that was already played in-queue', async () => {
const a = makeTrack({ id: 'a' });
const b = makeTrack({ id: 'b' });
seedQueue([a, b], { index: 1, currentTrack: b, serverId: 's1' });
const playTrack = vi.fn();
usePlayerStore.setState({ playTrack });
await playTimelineHistoryTrack('s1', 'a');
expect(playTrack).toHaveBeenCalledTimes(1);
const [, queue] = playTrack.mock.calls[0]!;
expect(queue?.map((t: { id: string }) => t.id)).toEqual(['a', 'b', 'a']);
});
it('does not jump to the wrong server when track ids collide', async () => {
const b = makeTrack({ id: 'b' });
const s1Shared = makeTrack({ id: 'shared' });
const s2Shared = makeTrack({ id: 'shared', serverId: 's2' });
seedQueueResolver('s1', [s1Shared, b]);
seedQueueResolver('s2', [s2Shared]);
usePlayerStore.setState({
queueItems: [
{ serverId: 's1', trackId: 'shared' },
{ serverId: 's2', trackId: 'shared' },
{ serverId: 's1', trackId: 'b' },
],
queueIndex: 2,
currentTrack: b,
queueServerId: 's1',
playTrack: vi.fn(),
});
const playTrack = usePlayerStore.getState().playTrack as ReturnType<typeof vi.fn>;
await playTimelineHistoryTrack('s2', 'shared');
expect(playTrack).toHaveBeenCalledTimes(1);
const [, queue, , , targetIdx] = playTrack.mock.calls[0]!;
expect(targetIdx).toBe(3);
expect(queue?.map((t: { id: string }) => t.id)).toEqual(['shared', 'shared', 'b', 'shared']);
});
});
@@ -0,0 +1,52 @@
import { usePlayerStore } from '../../store/playerStore';
import type { QueueItemRef } from '../../store/playerStoreTypes';
import { getQueueTracksView, resolveQueueTrack } from '../library/queueTrackView';
import { resolveBatch } from '../library/queueTrackResolver';
import { findQueueItemRefIndex, sameQueueItemRef } from '../playback/queueIdentity';
/**
* Play a timeline history row without replacing the queue. Upcoming slots jump
* in place; everything else inserts after the current track (play-now semantics).
*/
export async function playTimelineHistoryTrack(
serverId: string,
trackId: string,
canonicalQueue?: QueueItemRef[],
): Promise<void> {
const ref = { serverId, trackId };
await resolveBatch([ref]);
const track = resolveQueueTrack(ref);
const state = usePlayerStore.getState();
const { queueItems, queueIndex, currentTrack, playTrack } = state;
const lookup = canonicalQueue ?? queueItems;
const absIdx = findQueueItemRefIndex(lookup, ref);
const currentRef = queueIndex >= 0 ? lookup[queueIndex] : undefined;
if (
absIdx === queueIndex
&& currentTrack
&& currentRef
&& sameQueueItemRef(currentRef, ref)
) {
return;
}
if (absIdx > queueIndex) {
playTrack(track, undefined, undefined, undefined, absIdx);
return;
}
if (!currentTrack || queueItems.length === 0) {
playTrack(track, [track]);
return;
}
const resolved = getQueueTracksView(queueItems);
const insertAt = Math.min(queueIndex + 1, resolved.length);
const newQueue = [
...resolved.slice(0, insertAt),
track,
...resolved.slice(insertAt),
];
playTrack(track, newQueue, undefined, undefined, insertAt);
}
@@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
import { timelineBootstrapIndexReady } from './timelineBootstrapReady';
vi.mock('../library/libraryReady', () => ({
libraryIsReady: vi.fn(),
}));
import { libraryIsReady } from '../library/libraryReady';
describe('timelineBootstrapIndexReady', () => {
beforeEach(() => {
vi.mocked(libraryIsReady).mockReset();
useAuthStore.setState({
servers: [
{ id: 's1', name: 'A', url: 'https://a', username: 'u', password: 'p' },
{ id: 's2', name: 'B', url: 'https://b', username: 'u', password: 'p' },
],
activeServerId: 's1',
} as never);
useLibraryIndexStore.setState({
masterEnabled: true,
isIndexEnabled: id => !!id,
indexedServerIds: ids => ids,
});
});
it('returns true when no servers are configured', async () => {
useAuthStore.setState({ servers: [], activeServerId: null } as never);
await expect(timelineBootstrapIndexReady()).resolves.toBe(true);
});
it('returns true when any indexed server is ready', async () => {
vi.mocked(libraryIsReady).mockImplementation(async id => id === 's2');
await expect(timelineBootstrapIndexReady()).resolves.toBe(true);
});
it('returns false when no indexed server is ready yet', async () => {
vi.mocked(libraryIsReady).mockResolvedValue(false);
await expect(timelineBootstrapIndexReady()).resolves.toBe(false);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
import { libraryIsReady } from '../library/libraryReady';
/**
* Timeline cold bootstrap reads local `play_session` (cross-server). Gate on at
* least one indexed profile being ready so JOIN metadata is trustworthy; when no
* servers are configured, allow fetch immediately.
*/
export async function timelineBootstrapIndexReady(): Promise<boolean> {
const servers = useAuthStore.getState().servers;
if (servers.length === 0) return true;
const indexed = useLibraryIndexStore
.getState()
.indexedServerIds(servers.map(s => s.id));
if (indexed.length === 0) return true;
for (const serverId of indexed) {
if (await libraryIsReady(serverId)) return true;
}
return false;
}
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { bootstrapTrackFromPlaySession, timelineHistoryToQueueRefs } from './timelineHistoryRefs';
describe('timelineHistoryRefs', () => {
it('maps history rows to queue refs', () => {
expect(timelineHistoryToQueueRefs([
{ serverId: 's1', trackId: 't1', playedAtMs: 1 },
{ serverId: 's2', trackId: 't2', playedAtMs: 2 },
])).toEqual([
{ serverId: 's1', trackId: 't1' },
{ serverId: 's2', trackId: 't2' },
]);
});
it('seeds bootstrap tracks with album cover metadata', () => {
const track = bootstrapTrackFromPlaySession({
serverId: 's2',
trackId: 't1',
title: 'Song',
artist: 'Artist',
album: 'Album',
albumId: 'al-1',
coverArtId: 'cover-1',
startedAtMs: 1,
listenedSec: 30,
completion: 'full',
});
expect(track.albumId).toBe('al-1');
expect(track.coverArt).toBe('cover-1');
expect(track.serverId).toBe('s2');
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { QueueItemRef } from '../../store/playerStoreTypes';
import type { PlaySessionRecentTrack } from '../../api/library';
import type { TimelinePlayedRef } from '../../store/timelineSessionHistory';
import type { Track } from '../../store/playerStoreTypes';
export function timelineHistoryToQueueRefs(
history: TimelinePlayedRef[],
): QueueItemRef[] {
return history.map(row => ({ serverId: row.serverId, trackId: row.trackId }));
}
export function bootstrapTrackFromPlaySession(row: PlaySessionRecentTrack): Track {
const albumId = row.albumId ?? '';
const coverArt = row.coverArtId ?? albumId;
return {
id: row.trackId,
title: row.title,
artist: row.artist ?? '',
album: row.album ?? '',
albumId,
coverArt,
duration: 0,
serverId: row.serverId,
};
}