mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
6ea0acede5
* feat(playback): stream buffering UI, ranged M4A tail prefetch, demuxer fix Defer seekbar/progress until HTTP stream is armed for both legacy and RangedHttpSource; show buffering overlay on cover art. Add MP4 tail prefetch and Symphonia isomp4 bounded-mdat/moov-at-EOF probing so moov-at-end M4A can start without reading the full mdat. * feat(hot-cache): spill large ranged streams to disk for promote When a ranged HTTP download completes above the 64 MiB RAM promote cap, write the existing buffer once to app-data stream-spill/ and register it for hot-cache promote (rename) and replay via fetch_data. Analysis seeds from the spill file up to the local-file cap (512 MiB). * fix(ui): stream buffering — grayscale cover and static clock icon Desaturate player and queue cover art while isPlaybackBuffering; keep a non-animated clock overlay for visibility without the spinning animation. * fix(playback): review follow-up — tests, i18n, spill cleanup, changelog Clippy and test layout fixes; stream spill orphan cleanup on startup; buffering flag guard in progress handler; bufferingStream in all player locales; CHANGELOG and contributor credits for stream/M4A work. * docs: attribute stream buffering and M4A streaming to PR #737 * test(audio): avoid create_engine in stream spill unit test CI runners have no audio output device; test spill take/consume via the Mutex slot only, matching install_stream_completed_spill tests.
123 lines
5.3 KiB
TypeScript
123 lines
5.3 KiB
TypeScript
import React, { useEffect } from 'react';
|
|
import type { SeekbarStyle } from '../store/authStoreTypes';
|
|
import { getPlaybackProgressSnapshot } from '../store/playbackProgress';
|
|
import {
|
|
ANIMATED_STYLES, AnimState, INTERPOLATION_PAINT_MIN_MS,
|
|
isBarQuantizedSeekStyle, quantizeProgressByBars,
|
|
} from '../utils/waveform/waveformSeekHelpers';
|
|
import { drawSeekbar } from '../utils/waveform/waveformSeekRenderers';
|
|
|
|
interface Args {
|
|
duration: number;
|
|
isPlaying: boolean;
|
|
previewFreezesMainSeekbar: boolean;
|
|
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
|
heightsRef: React.MutableRefObject<Float32Array | null>;
|
|
styleRef: React.MutableRefObject<SeekbarStyle>;
|
|
progressRef: React.MutableRefObject<number>;
|
|
bufferedRef: React.MutableRefObject<number>;
|
|
visualProgressRef: React.MutableRefObject<number>;
|
|
visualTargetProgressRef: React.MutableRefObject<number>;
|
|
progressAnchorRef: React.MutableRefObject<{ progress: number; atMs: number }>;
|
|
animStateRef: React.MutableRefObject<AnimState>;
|
|
isDraggingRef: React.MutableRefObject<boolean>;
|
|
wheelPreviewFractionRef: React.MutableRefObject<number | null>;
|
|
wheelPreviewUntilRef: React.MutableRefObject<number>;
|
|
pendingCommittedSeekRef: React.MutableRefObject<{ fraction: number; setAtMs: number } | null>;
|
|
}
|
|
|
|
/** Smooth interpolation rAF loop — predicts playhead position between sparse
|
|
* transport heartbeats, with proper anchor reset on resume. Inactive while
|
|
* paused, dragging, wheel-preview, or pending-committed-seek. */
|
|
export function useWaveformInterpolation({
|
|
duration, isPlaying, previewFreezesMainSeekbar,
|
|
canvasRef, heightsRef, styleRef,
|
|
progressRef, bufferedRef, visualProgressRef, visualTargetProgressRef,
|
|
progressAnchorRef, animStateRef, isDraggingRef,
|
|
wheelPreviewFractionRef, wheelPreviewUntilRef, pendingCommittedSeekRef,
|
|
}: Args) {
|
|
useEffect(() => {
|
|
if (!isPlaying || previewFreezesMainSeekbar || duration <= 0 || !isFinite(duration)) return;
|
|
// This effect is torn down while paused, so `progressAnchorRef.atMs` is not refreshed.
|
|
// On resume the first `tick` would add the entire pause duration to `elapsedSec` and
|
|
// overshoot the playhead until the next transport heartbeat corrects it.
|
|
const snap = getPlaybackProgressSnapshot();
|
|
const raw = snap.buffering || snap.currentTime < 0.005 ? 0 : snap.progress;
|
|
progressRef.current = raw;
|
|
progressAnchorRef.current = {
|
|
progress: raw,
|
|
atMs: performance.now(),
|
|
};
|
|
const resumeVisual = isBarQuantizedSeekStyle(styleRef.current)
|
|
? quantizeProgressByBars(raw)
|
|
: raw;
|
|
visualTargetProgressRef.current = resumeVisual;
|
|
visualProgressRef.current = resumeVisual;
|
|
|
|
let rafId: number | null = null;
|
|
let lastPaintAt = 0;
|
|
const tick = (now: number) => {
|
|
if (document.hidden || window.__psyHidden) {
|
|
rafId = requestAnimationFrame(tick);
|
|
return;
|
|
}
|
|
if (isDraggingRef.current) {
|
|
rafId = requestAnimationFrame(tick);
|
|
return;
|
|
}
|
|
const wheelPreviewFraction = wheelPreviewFractionRef.current;
|
|
if (wheelPreviewFraction != null && Date.now() < wheelPreviewUntilRef.current) {
|
|
rafId = requestAnimationFrame(tick);
|
|
return;
|
|
}
|
|
if (pendingCommittedSeekRef.current) {
|
|
rafId = requestAnimationFrame(tick);
|
|
return;
|
|
}
|
|
const snap = getPlaybackProgressSnapshot();
|
|
if (snap.buffering || snap.currentTime < 0.005) {
|
|
progressRef.current = 0;
|
|
visualTargetProgressRef.current = 0;
|
|
visualProgressRef.current = 0;
|
|
progressAnchorRef.current = { progress: 0, atMs: now };
|
|
rafId = requestAnimationFrame(tick);
|
|
return;
|
|
}
|
|
const anchor = progressAnchorRef.current;
|
|
const elapsedSec = Math.max(0, (now - anchor.atMs) / 1000);
|
|
const predicted = Math.max(0, Math.min(1, anchor.progress + elapsedSec / duration));
|
|
const nextTargetProgress = isBarQuantizedSeekStyle(styleRef.current)
|
|
? quantizeProgressByBars(predicted)
|
|
: predicted;
|
|
if (Math.abs(nextTargetProgress - visualTargetProgressRef.current) > 0.000001) {
|
|
visualTargetProgressRef.current = nextTargetProgress;
|
|
}
|
|
const currentVisual = visualProgressRef.current;
|
|
const targetVisual = visualTargetProgressRef.current;
|
|
const delta = targetVisual - currentVisual;
|
|
if (Math.abs(delta) > 0.000001) {
|
|
const smoothing = isBarQuantizedSeekStyle(styleRef.current) ? 0.22 : 0.28;
|
|
const nextVisualProgress = Math.abs(delta) < 0.002
|
|
? targetVisual
|
|
: currentVisual + delta * smoothing;
|
|
visualProgressRef.current = nextVisualProgress;
|
|
progressRef.current = nextVisualProgress;
|
|
const needsDirectDraw = !ANIMATED_STYLES.has(styleRef.current);
|
|
if (needsDirectDraw && now - lastPaintAt >= INTERPOLATION_PAINT_MIN_MS) {
|
|
const canvas = canvasRef.current;
|
|
if (canvas) {
|
|
drawSeekbar(canvas, styleRef.current, heightsRef.current, nextVisualProgress, bufferedRef.current, animStateRef.current);
|
|
lastPaintAt = now;
|
|
}
|
|
}
|
|
}
|
|
rafId = requestAnimationFrame(tick);
|
|
};
|
|
rafId = requestAnimationFrame(tick);
|
|
return () => {
|
|
if (rafId != null) cancelAnimationFrame(rafId);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [duration, isPlaying, previewFreezesMainSeekbar]);
|
|
}
|