mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
281e86fd3b
* 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.
141 lines
3.8 KiB
TypeScript
141 lines
3.8 KiB
TypeScript
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();
|
|
}
|