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).
### 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
### ESLint setup and a strict lint pass over the frontend
@@ -24,7 +24,7 @@ use crate::dto::{
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionRecentTrackDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
TrackArtifactDto, TrackFactDto, TrackRefDto,
};
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))
}
#[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]
pub fn library_purge_server(
runtime: State<'_, LibraryRuntime>,
@@ -346,6 +346,9 @@ pub struct PlaySessionDayTrackDto {
pub listened_sec: f64,
pub completion: String,
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)]
@@ -355,6 +358,9 @@ pub struct PlaySessionDayDetailDto {
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).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
@@ -13,7 +13,7 @@ use rusqlite::{params, OptionalExtension};
use crate::dto::{
PlaySessionDayDetailDto, PlaySessionDayTrackDto, PlaySessionDayTotalsDto,
PlaySessionHeatmapDayDto, PlaySessionInputDto, PlaySessionRecentDayDto,
PlaySessionYearBoundsDto, PlaySessionYearSummaryDto,
PlaySessionRecentTrackDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto,
};
use crate::store::LibraryStore;
@@ -22,6 +22,21 @@ use completion::{
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 {
total_listened_sec: f64,
track_play_count: u32,
@@ -223,24 +238,15 @@ impl<'a> PlaySessionRepository<'a> {
let mut stmt = conn.prepare(
"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 \
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 \
ORDER BY ps.started_at_ms DESC",
)?;
let tracks = stmt
.query_map(params![date_iso], |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)?,
})
})?
.query_map(params![date_iso], map_play_session_track_row)?
.collect::<rusqlite::Result<Vec<_>>>()?;
let plays: Vec<PlaySpan> = tracks
@@ -346,4 +352,29 @@ impl<'a> PlaySessionRepository<'a> {
})
.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!(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_year_bounds,
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_migrate_server_index_keys,
psysonic_library::commands::library_delete_server_data,
+29 -1
View File
@@ -709,7 +709,7 @@ export type PlaySessionDayTrack = {
title: string;
artist: string | null;
listenedSec: number;
completion: 'partial' | 'full' | string;
completion: 'partial' | 'full';
startedAtMs: number;
};
@@ -840,6 +840,34 @@ export function libraryGetPlayerStatsRecentDays(limit = 30): Promise<PlaySession
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 ───────────────────────────────────────────────
export interface LibrarySyncProgressPayload {
+10 -3
View File
@@ -55,6 +55,7 @@ beforeEach(() => {
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('discord_update_presence', () => undefined);
onInvoke('library_get_recent_play_sessions', () => []);
});
describe('QueuePanel — render surface', () => {
@@ -154,14 +155,20 @@ describe('QueuePanel — display mode', () => {
it('header mode-toggle button advances queueDisplayMode (default queue → timeline)', () => {
seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() });
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');
expect(toggle?.getAttribute('aria-label')).toBe('Timeline');
toggle!.click();
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', () => {
+18 -3
View File
@@ -33,6 +33,8 @@ import { QueueToolbar } from './queuePanel/QueueToolbar';
import { QueueList } from './queuePanel/QueueList';
import { QueueTabBar } from './queuePanel/QueueTabBar';
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
import { useTimelineBootstrapOnMode, useTimelineHistoryResolver, useTimelinePlayHistory } from '../hooks/useTimelinePlayHistory';
import { buildTimelineDisplayRows } from '../utils/queue/buildTimelineDisplayRows';
import { activeServerQueueTrackIds } from '../utils/playback/trackServerScope';
export default function QueuePanel() {
@@ -120,6 +122,17 @@ function QueuePanelHostOrSolo() {
const setIsNowPlayingCollapsed = useAuthStore(s => s.setQueueNowPlayingCollapsed);
const queueDisplayMode = useAuthStore(s => s.queueDisplayMode);
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 durationMode = useAuthStore(s => s.queueDurationDisplayMode);
const setDurationMode = useAuthStore(s => s.setQueueDurationDisplayMode);
@@ -221,9 +234,9 @@ function QueuePanelHostOrSolo() {
// index for every index-based handler (play / remove / reorder / drag).
const displayBaseIndex = queueDisplayMode === 'queue' ? Math.max(0, queueIndex + 1) : 0;
const displayItems = displayBaseIndex > 0 ? queueItems.slice(displayBaseIndex) : queueItems;
// In queue mode the list can be empty while the queue still holds the
// now-playing (last) track — say "no upcoming" rather than "queue is empty".
const queueEmptyLabel = queueDisplayMode === 'queue' && queueItems.length > 0
const queueEmptyLabel = queueDisplayMode === 'timeline'
? (timelineRows && timelineRows.length > 0 ? '' : t('queue.emptyQueue'))
: queueDisplayMode === 'queue' && queueItems.length > 0
? t('queue.noUpcoming')
: t('queue.emptyQueue');
@@ -350,6 +363,8 @@ function QueuePanelHostOrSolo() {
<QueueList
queue={displayItems}
timelineRows={timelineRows}
canonicalQueue={queueItems}
queueIndex={queueIndex}
displayBaseIndex={displayBaseIndex}
queueDisplayMode={queueDisplayMode}
+254 -124
View File
@@ -13,6 +13,10 @@ import {
getQueueResolverVersion,
subscribeQueueResolver,
} 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 = (
payload: { data: string; label: string },
@@ -21,18 +25,14 @@ type StartDrag = (
) => void;
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[];
/** 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;
/** 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;
queueDisplayMode: QueueDisplayMode;
/** Label for the empty list (differs between "queue is empty" and "no
* upcoming tracks"). */
emptyLabel: string;
contextMenu: PlayerState['contextMenu'];
playTrack: PlayerState['playTrack'];
@@ -48,80 +48,79 @@ interface Props {
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 };
export function QueueList({
queue, queueIndex, displayBaseIndex, queueDisplayMode, emptyLabel,
queue, timelineRows, canonicalQueue, queueIndex, displayBaseIndex, queueDisplayMode, emptyLabel,
contextMenu, playTrack, activeTab, queueListRef,
suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget,
startDrag, orbitAttributionLabel, luckyRolling, t,
}: 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);
// Virtualize so a 10k+ Artist-Radio queue keeps DOM at O(visible rows).
// Scroll element is the OverlayScrollArea viewport (`queueListRef`); rows have
// 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.
const usingTimeline = queueDisplayMode === 'timeline' && timelineRows != null;
const rowCount = usingTimeline ? timelineRows.length : queue.length;
// eslint-disable-next-line react-hooks/incompatible-library
const rowVirtualizer = useVirtualizer({
count: queue.length,
count: rowCount,
getScrollElement: () => queueListRef.current,
estimateSize: () => 52,
overscan: 10,
getItemKey: i => `${queue[i].trackId}:${i}`,
// Start with a sensible viewport height so rows render before the
// ResizeObserver reports the real size (SSR / jsdom, where the observer
// never fires). The real height overrides this on first measure.
getItemKey: i => {
if (usingTimeline) return timelineRows[i]!.key;
return `${queue[i].trackId}:${i}`;
},
initialRect: INITIAL_RECT,
});
const virtualItems = rowVirtualizer.getVirtualItems();
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(() => {
if (suppressNextAutoScrollRef.current) {
suppressNextAutoScrollRef.current = false;
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' });
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' });
});
return () => cancelAnimationFrame(id);
};
if (queueDisplayMode === 'queue') {
// Upcoming-only: the next track is the first row — keep it pinned at the
// top. The played track already dropped out of the slice.
return pinToTop(0, displayBaseIndex);
if (queueIndex < 0) return;
return pinToTop(0, `[data-queue-idx="${displayBaseIndex}"]`);
}
if (queueDisplayMode === 'timeline') {
// 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.
if (queueIndex < 0) return;
const viewport = queueListRef.current;
if (viewport) {
const rowEl = viewport.querySelector<HTMLElement>(`[data-queue-idx="${queueIndex}"]`);
@@ -129,95 +128,55 @@ export function QueueList({
const rowRect = rowEl.getBoundingClientRect();
const viewRect = viewport.getBoundingClientRect();
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
}, [queueIndex, activeTab, queueDisplayMode]);
}, [queueIndex, activeTab, queueDisplayMode, rowCount, usingTimeline]);
return (
<OverlayScrollArea
viewportRef={queueListRef}
className="queue-list-wrap"
viewportClassName="queue-list"
measureDeps={[activeTab, queue.length, totalSize]}
railInset="panel"
viewportScrollBehaviorAuto={isQueueDrag}
>
{queue.length === 0 ? (
<div className="queue-empty">
{emptyLabel}
</div>
) : (
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
{virtualItems.map(vi => {
const idx = vi.index;
// Local index addresses `queue` (the displayed slice); the absolute
// index addresses the canonical queue and drives every handler that
// mutates / selects (play, context menu, drag, reorder, drop target).
const absIdx = displayBaseIndex + idx;
const base = queue[idx];
const track = resolveQueueTrack(base);
const isPlaying = absIdx === queueIndex;
const isTimeline = queueDisplayMode === 'timeline';
const isPast = isTimeline && absIdx < queueIndex;
const isFirstAutoAdded = base.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
const isFirstRadioAdded = base.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
let dragStyle: React.CSSProperties = {};
if (isQueueDrag && psyDragFromIdxRef.current === absIdx) {
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
} else if (isQueueDrag && externalDropTarget?.idx === absIdx) {
if (externalDropTarget.before) {
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
} else {
dragStyle = { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
}
}
return (
<div
key={vi.key}
data-index={idx}
ref={rowVirtualizer.measureElement}
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 && (
<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>
)}
<div
data-queue-idx={absIdx}
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === absIdx ? 'context-active' : ''}`}
onClick={() => {
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;
// Same-queue jump: undefined keeps the canonical refs; the row
// index lands a click on a duplicate track on *this* slot, not
// the first occurrence (issue #500).
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;
@@ -254,6 +213,177 @@ export function QueueList({
{formatTrackTime(track.duration)}
</div>
</div>
);
};
return (
<OverlayScrollArea
viewportRef={queueListRef}
className="queue-list-wrap"
viewportClassName="queue-list"
measureDeps={[activeTab, rowCount, totalSize]}
railInset="panel"
viewportScrollBehaviorAuto={isQueueDrag}
>
{rowCount === 0 ? (
emptyLabel ? (
<div className="queue-empty">{emptyLabel}</div>
) : null
) : (
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
{virtualItems.map(vi => {
const idx = vi.index;
if (usingTimeline && timelineRows) {
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 base = queue[idx];
const track = resolveQueueTrack(base);
const isPlaying = absIdx === queueIndex;
const isPast = false;
const isFirstAutoAdded = base.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
const isFirstRadioAdded = base.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
let dragStyle: React.CSSProperties = {};
if (isQueueDrag && psyDragFromIdxRef.current === absIdx) {
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
} else if (isQueueDrag && externalDropTarget?.idx === absIdx) {
if (externalDropTarget.before) {
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
} else {
dragStyle = { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
}
}
return (
<div
key={vi.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,
localIndex: idx,
isPlaying,
isPast,
isHistory: false,
base,
dragStyle,
})}
{luckyRolling && isPlaying && (
<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)',
'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)',
'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',
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.',
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',
queueToolbarReset: 'Zurücksetzen',
queueToolbarSeparator: 'Trennlinie',
+1 -1
View File
@@ -504,7 +504,7 @@ export const settings = {
queueModeTitle: 'Queue Display Mode',
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.',
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',
queueToolbarReset: 'Reset to default',
queueToolbarSeparator: 'Separator',
+1 -1
View File
@@ -459,7 +459,7 @@ export const settings = {
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.',
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',
queueToolbarReset: 'Restablecer a predeterminado',
queueToolbarSeparator: 'Separador',
+1 -1
View File
@@ -447,7 +447,7 @@ export const settings = {
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.',
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',
queueToolbarReset: 'Réinitialiser',
queueToolbarSeparator: 'Séparateur',
+1 -1
View File
@@ -503,7 +503,7 @@ export const settings = {
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.',
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',
queueToolbarReset: 'Visszaállítás alapértelmezettre',
queueToolbarSeparator: 'Elválasztó',
+1 -1
View File
@@ -497,7 +497,7 @@ export const settings = {
queueModeTitle: 'キュー表示モード',
queueModeQueueSub: '今後のトラックのみ表示します。現在のトラックはヘッダーに残り、再生済みになるとリストから外れます。',
queueModePlaylistSub: 'キュー全体をリストに残し、現在のトラックを上部で強調表示します。再生済みトラックも残ります。',
queueModeTimelineSub: '現在のトラックを中央に置き、上に再生履歴、下に今後のトラック (シャッフル順) を表示します。',
queueModeTimelineSub: '現在のトラックをリスト上部に固定し、上にスクロール可能な再生履歴、下に今後のトラック (シャッフル順) を表示します。',
queueToolbarTitle: 'キューツールバー',
queueToolbarReset: '既定に戻す',
queueToolbarSeparator: '区切り',
+1 -1
View File
@@ -446,7 +446,7 @@ export const settings = {
queueModeTitle: 'Visningsmodus for kø',
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.',
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',
queueToolbarReset: 'Tilbakestill til standard',
queueToolbarSeparator: 'Skilje',
+1 -1
View File
@@ -447,7 +447,7 @@ export const settings = {
queueModeTitle: 'Weergavemodus wachtrij',
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.',
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',
queueToolbarReset: 'Standaard herstellen',
queueToolbarSeparator: 'Scheiding',
+1 -1
View File
@@ -504,7 +504,7 @@ export const settings = {
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.',
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',
queueToolbarReset: 'Przywróć domyślne',
queueToolbarSeparator: 'Separator',
+1 -1
View File
@@ -462,7 +462,7 @@ export const settings = {
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ă.',
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ă',
queueToolbarReset: 'Resetează la implicit',
queueToolbarSeparator: 'Separator',
+1 -1
View File
@@ -517,7 +517,7 @@ export const settings = {
queueModeTitle: 'Режим отображения очереди',
queueModeQueueSub: 'Показывает только предстоящие треки. Текущий трек остаётся в заголовке и исчезает из списка после воспроизведения.',
queueModePlaylistSub: 'Сохраняет всю очередь в списке, текущий трек выделен вверху; воспроизведённые треки остаются.',
queueModeTimelineSub: 'Центрировать текущий трек: история воспроизведения сверху, предстоящие треки (в порядке перемешивания) снизу.',
queueModeTimelineSub: 'Текущий трек сверху списка; история воспроизведения прокручивается выше, предстоящие треки (в порядке перемешивания) — ниже.',
queueToolbarTitle: 'Панель инструментов очереди',
queueToolbarReset: 'Сбросить',
queueToolbarSeparator: 'Разделитель',
+1 -1
View File
@@ -446,7 +446,7 @@ export const settings = {
queueModeTitle: '队列显示模式',
queueModeQueueSub: '仅显示即将播放的曲目。当前曲目保留在标题栏中,播放完毕后从列表移除。',
queueModePlaylistSub: '在列表中保留整个队列,当前曲目在顶部高亮显示;已播放的曲目保留。',
queueModeTimelineSub: '当前曲目居中显示,已播放历史在上方,即将播放的曲目(按随机顺序)在下方。',
queueModeTimelineSub: '当前曲目保持在列表顶部,上方可滚动查看播放历史,下方为即将播放的曲目(按随机顺序)。',
queueToolbarTitle: '队列工具栏',
queueToolbarReset: '重置为默认',
queueToolbarSeparator: '分隔符',
+14 -1
View File
@@ -16,6 +16,7 @@ import {
playListenSessionOnTrackSwitched,
playListenSessionOpen,
} from './playListenSession';
import { appendTimelineLeaveTrack } from './timelineSessionHistory';
import { getPerfProbeFlags } from '../utils/perf/perfFlags';
import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
import {
@@ -477,6 +478,15 @@ export function handleAudioEnded(): void {
// opens a fresh session via playbackReportStart.
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.
if (usePlayerStore.getState().currentRadio) {
setIsAudioPaused(false);
@@ -531,7 +541,10 @@ export function handleAudioTrackSwitched(_duration: number): void {
if (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;
let nextTrack: Track | null = null;
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 { getMusicNetworkRuntimeOrNull } from '../music-network';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
@@ -66,6 +66,7 @@ import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import { pushQueueOnPlaybackStart } from './queueSync';
import { playListenSessionFinalize } from './playListenSession';
import { pushQueueUndoFromGetter } from './queueUndo';
import { appendTimelineLeaveTrack } from './timelineSessionHistory';
import { stopRadio } from './radioPlayer';
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
import { clearSeekDebounce } from './seekDebounce';
@@ -184,7 +185,21 @@ export function runPlayTrack(
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;
clearAllPlaybackScheduleTimers();
@@ -247,6 +262,19 @@ export function runPlayTrack(
const playIdx = idx >= 0 ? idx : 0;
const playingRef = replacing ? undefined : state.queueItems[playIdx];
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
// whole queue). On replace they come from the provided Track[]; on navigation
// from the resolver cache (the bridge keeps that window warm).
+19
View File
@@ -90,6 +90,25 @@ describe('playbackReportStart', () => {
expect(reportPlaybackMock).not.toHaveBeenCalled();
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', () => {
+34 -6
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
* `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 {
if (!serverId || !nowPlayingEnabled()) return;
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;
}
const isNewSession = !session || session.trackId !== trackId || session.serverId !== serverId;
session = { serverId, trackId };
if (isNewSession) {
void send(serverId, trackId, 'starting').then(() => send(serverId, trackId, 'playing'));
} else {
void send(serverId, trackId, 'playing');
openExtensionSession(serverId, trackId, isNewSession);
};
if (serverChanged) {
session = null;
void stopExtensionSession(prev).then(openNext);
return;
}
openNext();
}
/** Engine-confirmed playback / resume / heartbeat (extension path only). */
+11
View File
@@ -31,6 +31,10 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
}));
import { usePlayerStore } from './playerStore';
import {
appendTimelineSessionPlay,
getTimelineSessionHistorySnapshot,
} from './timelineSessionHistory';
import { onInvoke } from '@/test/mocks/tauri';
import { resetPlayerStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
@@ -181,6 +185,13 @@ describe('clearQueue', () => {
usePlayerStore.getState().clearQueue();
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', () => {
+2
View File
@@ -24,6 +24,7 @@ import {
clearQueueServerForPlayback,
ensureQueueServerPinned,
} from '../utils/playback/playbackServer';
import { clearTimelineSessionHistory } from './timelineSessionHistory';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
@@ -227,6 +228,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
clearSeekDebounce(); clearSeekTarget();
clearRadioSessionSeenIds();
setCurrentRadioArtistId(null);
clearTimelineSessionHistory();
clearQueueServerForPlayback();
set({ queueItems: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 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);
*/
import { _resetQueueUndoStacksForTest } from '@/store/queueUndo';
import { _resetTimelineSessionHistoryForTest } from '@/store/timelineSessionHistory';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { usePreviewStore } from '@/store/previewStore';
@@ -35,6 +36,7 @@ export function resetPlayerStore(): void {
usePlayerStore.setState(INITIAL_PLAYER_STATE, true);
// Module-scoped queue undo/redo stacks live outside the Zustand state.
_resetQueueUndoStacksForTest();
_resetTimelineSessionHistoryForTest();
}
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
* 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;
}
/** 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
* 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,
};
}