mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(playback): stream buffering UI, M4A moov-at-end streaming, hot-cache spill (#737)
* 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.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { PlaybackBufferingOverlay } from './PlaybackBufferingOverlay';
|
||||
|
||||
describe('PlaybackBufferingOverlay', () => {
|
||||
it('exposes buffering status for assistive tech', () => {
|
||||
renderWithProviders(<PlaybackBufferingOverlay />);
|
||||
expect(
|
||||
screen.getByRole('status', { name: 'Loading track from server' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Clock } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** Shown over cover art while an HTTP stream is still opening / buffering. */
|
||||
export function PlaybackBufferingOverlay() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="playback-buffering-overlay"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={t('player.bufferingStream')}
|
||||
>
|
||||
<Clock size={26} strokeWidth={2} className="playback-buffering-overlay__icon" aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import LastfmIcon from '../LastfmIcon';
|
||||
import MarqueeText from '../MarqueeText';
|
||||
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||
import StarRating from '../StarRating';
|
||||
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import {
|
||||
usePlayerBarLayoutStore,
|
||||
type PlayerBarLayoutItemId,
|
||||
@@ -52,6 +54,7 @@ export function PlayerTrackInfo({
|
||||
userRatingOverrides, setUserRatingOverride, toggleFullscreen,
|
||||
navigate, openContextMenu, t,
|
||||
}: Props) {
|
||||
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
||||
const layoutItems = usePlayerBarLayoutStore(s => s.items);
|
||||
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
||||
layoutItems.find(i => i.id === id)?.visible !== false;
|
||||
@@ -59,7 +62,7 @@ export function PlayerTrackInfo({
|
||||
return (
|
||||
<div className="player-track-info">
|
||||
<div
|
||||
className={`player-album-art-wrap ${currentTrack && !isRadio && !showPreviewMeta ? 'clickable' : ''}`}
|
||||
className={`player-album-art-wrap${showBufferingOverlay && !isRadio && !showPreviewMeta ? ' playback-buffering' : ''}${currentTrack && !isRadio && !showPreviewMeta ? ' clickable' : ''}`}
|
||||
onClick={() => !isRadio && !showPreviewMeta && currentTrack && toggleFullscreen()}
|
||||
data-tooltip={!isRadio && !showPreviewMeta && currentTrack ? t('player.openFullscreen') : undefined}
|
||||
>
|
||||
@@ -93,6 +96,9 @@ export function PlayerTrackInfo({
|
||||
<Maximize2 size={16} />
|
||||
</div>
|
||||
)}
|
||||
{showBufferingOverlay && !isRadio && !showPreviewMeta && (
|
||||
<PlaybackBufferingOverlay />
|
||||
)}
|
||||
</div>
|
||||
<div className="player-track-meta">
|
||||
{showPreviewMeta && (
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { loudnessGainPlaceholderUntilCacheDb } from '../../utils/audio/loudnessPlaceholder';
|
||||
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/audio/loudnessPreAnalysisSlider';
|
||||
import { QueueLufsTargetMenu } from './QueueLufsTargetMenu';
|
||||
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
|
||||
interface Props {
|
||||
currentTrack: Track;
|
||||
@@ -45,6 +47,7 @@ export function QueueCurrentTrack({
|
||||
reanalyzeLoudnessForTrack, setLoudnessTargetLufs, lufsTgtOpen, setLufsTgtOpen,
|
||||
lufsTgtBtnRef, lufsTgtMenuRef, lufsTgtPopStyle, t,
|
||||
}: Props) {
|
||||
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
||||
return (
|
||||
<div className="queue-current-track">
|
||||
{(() => {
|
||||
@@ -191,12 +194,13 @@ export function QueueCurrentTrack({
|
||||
);
|
||||
})()}
|
||||
<div className="queue-current-track-body">
|
||||
<div className="queue-current-cover">
|
||||
<div className={`queue-current-cover${showBufferingOverlay ? ' playback-buffering' : ''}`}>
|
||||
{currentTrack.coverArt ? (
|
||||
<img src={currentCoverSrc} alt="" loading="eager" />
|
||||
) : (
|
||||
<div className="fallback"><Music size={32} /></div>
|
||||
)}
|
||||
{showBufferingOverlay && <PlaybackBufferingOverlay />}
|
||||
</div>
|
||||
<div className="queue-current-info">
|
||||
<h3 className="truncate">{currentTrack.title}</h3>
|
||||
|
||||
@@ -113,6 +113,8 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Now Playing: composite list keys on similar artists, album-card tracklist, and top songs — avoids duplicate React keys when Subsonic repeats ids (PR #703)',
|
||||
'Search: share links in live/mobile search + queue preview modal (PR #716)',
|
||||
'Multi-server: pin queue streams, cover art, links, context menu, and Now Playing to queue server (PR #717)',
|
||||
'M4A/MP4 streaming: moov-at-end tail prefetch and Symphonia isomp4 probe fix (PR #737)',
|
||||
'HTTP stream buffering — seekbar/timer at zero and cover overlay until playback arms (PR #737)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -42,7 +42,7 @@ export function useWaveformInterpolation({
|
||||
// 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.progress;
|
||||
const raw = snap.buffering || snap.currentTime < 0.005 ? 0 : snap.progress;
|
||||
progressRef.current = raw;
|
||||
progressAnchorRef.current = {
|
||||
progress: raw,
|
||||
@@ -74,6 +74,15 @@ export function useWaveformInterpolation({
|
||||
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));
|
||||
|
||||
@@ -9,6 +9,7 @@ export const player = {
|
||||
prev: 'Vorheriger Titel',
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
bufferingStream: 'Loading track from server',
|
||||
previewActive: 'Vorschau läuft',
|
||||
previewLabel: 'Vorschau',
|
||||
delayModalTitle: 'Timer',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const player = {
|
||||
prev: 'Previous Track',
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
bufferingStream: 'Loading track from server',
|
||||
previewActive: 'Preview playing',
|
||||
previewLabel: 'Preview',
|
||||
delayModalTitle: 'Timer',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const player = {
|
||||
prev: 'Pista Anterior',
|
||||
play: 'Reproducir',
|
||||
pause: 'Pausa',
|
||||
bufferingStream: 'Loading track from server',
|
||||
previewActive: 'Vista previa activa',
|
||||
previewLabel: 'Vista previa',
|
||||
delayModalTitle: 'Temporizador',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const player = {
|
||||
prev: 'Piste précédente',
|
||||
play: 'Lecture',
|
||||
pause: 'Pause',
|
||||
bufferingStream: 'Loading track from server',
|
||||
previewActive: 'Aperçu en cours',
|
||||
previewLabel: 'Aperçu',
|
||||
delayModalTitle: 'Minuteur',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const player = {
|
||||
prev: 'Forrige spor',
|
||||
play: 'Spill av',
|
||||
pause: 'Pause',
|
||||
bufferingStream: 'Loading track from server',
|
||||
previewActive: 'Forhåndsvisning spilles av',
|
||||
previewLabel: 'Forhåndsvisning',
|
||||
delayModalTitle: 'Tidsur',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const player = {
|
||||
prev: 'Vorig nummer',
|
||||
play: 'Afspelen',
|
||||
pause: 'Pauzeren',
|
||||
bufferingStream: 'Loading track from server',
|
||||
previewActive: 'Voorbeeld speelt af',
|
||||
previewLabel: 'Voorbeeld',
|
||||
delayModalTitle: 'Timer',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const player = {
|
||||
prev: 'Piesa anterioară',
|
||||
play: 'Redă',
|
||||
pause: 'Pauză',
|
||||
bufferingStream: 'Loading track from server',
|
||||
previewActive: 'Previzualizează redarea',
|
||||
previewLabel: 'Previzualizare',
|
||||
delayModalTitle: 'Temporizator',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const player = {
|
||||
prev: 'Предыдущий трек',
|
||||
play: 'Играть',
|
||||
pause: 'Пауза',
|
||||
bufferingStream: 'Загрузка трека с сервера',
|
||||
previewActive: 'Превью воспроизводится',
|
||||
previewLabel: 'Превью',
|
||||
delayModalTitle: 'Таймер',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const player = {
|
||||
prev: '上一首',
|
||||
play: '播放',
|
||||
pause: '暂停',
|
||||
bufferingStream: 'Loading track from server',
|
||||
previewActive: '正在试听',
|
||||
previewLabel: '试听',
|
||||
delayModalTitle: '定时',
|
||||
|
||||
@@ -77,10 +77,14 @@ export type NormalizationStatePayload = {
|
||||
export function handleAudioPlaying(_duration: number): void {
|
||||
setDeferHotCachePrefetch(false);
|
||||
resetProgressEmitThrottles();
|
||||
usePlayerStore.setState({ isPlaying: true });
|
||||
usePlayerStore.setState({ isPlaying: true, isPlaybackBuffering: false });
|
||||
}
|
||||
|
||||
export function handleAudioProgress(current_time: number, duration: number): void {
|
||||
export function handleAudioProgress(
|
||||
current_time: number,
|
||||
duration: number,
|
||||
buffering = false,
|
||||
): void {
|
||||
bumpPerfCounter('audioProgressEvents');
|
||||
const perfFlags = getPerfProbeFlags();
|
||||
const progressUiDisabled = perfFlags.disablePlayerProgressUi;
|
||||
@@ -105,6 +109,9 @@ export function handleAudioProgress(current_time: number, duration: number): voi
|
||||
const store = usePlayerStore.getState();
|
||||
const track = store.currentTrack;
|
||||
if (!track) return;
|
||||
if (!store.currentRadio && store.isPlaybackBuffering !== buffering) {
|
||||
usePlayerStore.setState({ isPlaybackBuffering: buffering });
|
||||
}
|
||||
// Some backends can emit stale progress ticks shortly after pause/stop.
|
||||
// Ignoring them avoids reactivating UI redraw loops while transport is idle.
|
||||
const transportActive = store.isPlaying || store.currentRadio != null;
|
||||
@@ -114,7 +121,7 @@ export function handleAudioProgress(current_time: number, duration: number): voi
|
||||
setSeekFallbackVisualTarget(null);
|
||||
visualTarget = null;
|
||||
}
|
||||
let displayTime = current_time;
|
||||
let displayTime = buffering ? 0 : current_time;
|
||||
if (visualTarget && visualTarget.trackId === track.id) {
|
||||
const nearTarget = Math.abs(current_time - visualTarget.seconds) <= 2.0;
|
||||
if (nearTarget) {
|
||||
@@ -142,8 +149,9 @@ export function handleAudioProgress(current_time: number, duration: number): voi
|
||||
) {
|
||||
emitPlaybackProgress({
|
||||
currentTime: displayTime,
|
||||
progress,
|
||||
progress: buffering ? 0 : progress,
|
||||
buffered: 0,
|
||||
buffering,
|
||||
});
|
||||
markLiveProgressEmit(nowLive);
|
||||
}
|
||||
@@ -315,6 +323,7 @@ export function handleAudioEnded(): void {
|
||||
setIsAudioPaused(false);
|
||||
usePlayerStore.setState({
|
||||
isPlaying: false,
|
||||
isPlaybackBuffering: false,
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
buffered: 0,
|
||||
@@ -386,6 +395,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
|
||||
normalizationDbgTrackId: nextTrack.id,
|
||||
queueIndex: newIndex,
|
||||
isPlaying: true,
|
||||
isPlaybackBuffering: switchPlaybackSource === 'stream',
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
buffered: 0,
|
||||
@@ -427,7 +437,7 @@ export function handleAudioError(message: string): void {
|
||||
showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error');
|
||||
|
||||
const gen = getPlayGeneration();
|
||||
usePlayerStore.setState({ isPlaying: false });
|
||||
usePlayerStore.setState({ isPlaying: false, isPlaybackBuffering: false });
|
||||
setTimeout(() => {
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
usePlayerStore.getState().next(false);
|
||||
|
||||
@@ -38,7 +38,7 @@ export function setupAudioEngineListeners(): () => void {
|
||||
|
||||
const pending = [
|
||||
listen<number>('audio:playing', ({ payload }) => handleAudioPlaying(payload)),
|
||||
listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) => {
|
||||
listen<{ current_time: number; duration: number; buffering?: boolean }>('audio:progress', ({ payload }) => {
|
||||
if (import.meta.env.DEV) {
|
||||
_devEventCount++;
|
||||
const now = Date.now();
|
||||
@@ -51,7 +51,7 @@ export function setupAudioEngineListeners(): () => void {
|
||||
_devWindowStart = now;
|
||||
}
|
||||
}
|
||||
handleAudioProgress(payload.current_time, payload.duration);
|
||||
handleAudioProgress(payload.current_time, payload.duration, payload.buffering ?? false);
|
||||
}),
|
||||
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||
|
||||
@@ -254,7 +254,10 @@ export function runPlayTrack(
|
||||
currentTime: initialTime,
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
isPlaying: true, // optimistic — reverted on error
|
||||
// HTTP stream: wait for Rust `audio:playing` so the seekbar does not
|
||||
// extrapolate while RangedHttpSource / legacy reader is still buffering.
|
||||
isPlaying: playbackSourceHint !== 'stream',
|
||||
isPlaybackBuffering: playbackSourceHint === 'stream',
|
||||
currentPlaybackSource: playbackSourceHint,
|
||||
enginePreloadedTrackId: keepPreloadHint ? track.id : null,
|
||||
});
|
||||
|
||||
@@ -19,12 +19,14 @@ afterEach(() => {
|
||||
describe('getPlaybackProgressSnapshot', () => {
|
||||
it('starts at zero', () => {
|
||||
const snap = getPlaybackProgressSnapshot();
|
||||
expect(snap).toEqual({ currentTime: 0, progress: 0, buffered: 0 });
|
||||
expect(snap).toEqual({ currentTime: 0, progress: 0, buffered: 0, buffering: false });
|
||||
});
|
||||
|
||||
it('returns the latest snapshot after an emit', () => {
|
||||
emitPlaybackProgress({ currentTime: 42, progress: 0.5, buffered: 0.7 });
|
||||
expect(getPlaybackProgressSnapshot()).toEqual({ currentTime: 42, progress: 0.5, buffered: 0.7 });
|
||||
expect(getPlaybackProgressSnapshot()).toEqual({
|
||||
currentTime: 42, progress: 0.5, buffered: 0.7, buffering: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,8 +36,10 @@ describe('subscribePlaybackProgress', () => {
|
||||
subscribePlaybackProgress(cb);
|
||||
emitPlaybackProgress({ currentTime: 1, progress: 0.1, buffered: 0.2 });
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb.mock.calls[0][0]).toEqual({ currentTime: 1, progress: 0.1, buffered: 0.2 });
|
||||
expect(cb.mock.calls[0][1]).toEqual({ currentTime: 0, progress: 0, buffered: 0 });
|
||||
expect(cb.mock.calls[0][0]).toEqual({
|
||||
currentTime: 1, progress: 0.1, buffered: 0.2, buffering: false,
|
||||
});
|
||||
expect(cb.mock.calls[0][1]).toEqual({ currentTime: 0, progress: 0, buffered: 0, buffering: false });
|
||||
});
|
||||
|
||||
it('returns an unsubscribe that detaches the listener', () => {
|
||||
|
||||
@@ -12,12 +12,15 @@ export type PlaybackProgressSnapshot = {
|
||||
currentTime: number;
|
||||
progress: number;
|
||||
buffered: number;
|
||||
/** Legacy HTTP stream still filling — do not extrapolate the seekbar. */
|
||||
buffering?: boolean;
|
||||
};
|
||||
|
||||
let playbackProgressSnapshot: PlaybackProgressSnapshot = {
|
||||
currentTime: 0,
|
||||
progress: 0,
|
||||
buffered: 0,
|
||||
buffering: false,
|
||||
};
|
||||
|
||||
const playbackProgressListeners = new Set<(
|
||||
@@ -26,16 +29,21 @@ const playbackProgressListeners = new Set<(
|
||||
) => void>();
|
||||
|
||||
export function emitPlaybackProgress(next: PlaybackProgressSnapshot): void {
|
||||
const normalized: PlaybackProgressSnapshot = {
|
||||
...next,
|
||||
buffering: next.buffering ?? false,
|
||||
};
|
||||
const prev = playbackProgressSnapshot;
|
||||
if (
|
||||
Math.abs(prev.currentTime - next.currentTime) < 0.005 &&
|
||||
Math.abs(prev.progress - next.progress) < 0.0002 &&
|
||||
Math.abs(prev.buffered - next.buffered) < 0.0002
|
||||
Math.abs(prev.currentTime - normalized.currentTime) < 0.005 &&
|
||||
Math.abs(prev.progress - normalized.progress) < 0.0002 &&
|
||||
Math.abs(prev.buffered - normalized.buffered) < 0.0002 &&
|
||||
(prev.buffering ?? false) === normalized.buffering
|
||||
) {
|
||||
return;
|
||||
}
|
||||
playbackProgressSnapshot = next;
|
||||
playbackProgressListeners.forEach(cb => cb(next, prev));
|
||||
playbackProgressSnapshot = normalized;
|
||||
playbackProgressListeners.forEach(cb => cb(normalized, prev));
|
||||
}
|
||||
|
||||
export function getPlaybackProgressSnapshot(): PlaybackProgressSnapshot {
|
||||
@@ -53,6 +61,6 @@ export function subscribePlaybackProgress(
|
||||
|
||||
/** Test-only: reset module state between specs so suites stay isolated. */
|
||||
export function _resetPlaybackProgressForTest(): void {
|
||||
playbackProgressSnapshot = { currentTime: 0, progress: 0, buffered: 0 };
|
||||
playbackProgressSnapshot = { currentTime: 0, progress: 0, buffered: 0, buffering: false };
|
||||
playbackProgressListeners.clear();
|
||||
}
|
||||
|
||||
@@ -238,3 +238,53 @@ describe('audio:progress throttling (live-emit guard)', () => {
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
|
||||
describe('audio:progress buffering flag', () => {
|
||||
it('sets isPlaybackBuffering from the optional buffering field', () => {
|
||||
const track = makeTrack({ duration: 100 });
|
||||
usePlayerStore.setState({
|
||||
currentTrack: track,
|
||||
isPlaying: true,
|
||||
isPlaybackBuffering: false,
|
||||
});
|
||||
|
||||
emitTauriEvent('audio:progress', {
|
||||
current_time: 0,
|
||||
duration: 100,
|
||||
buffering: true,
|
||||
});
|
||||
expect(usePlayerStore.getState().isPlaybackBuffering).toBe(true);
|
||||
|
||||
emitTauriEvent('audio:playing', 100);
|
||||
expect(usePlayerStore.getState().isPlaybackBuffering).toBe(false);
|
||||
});
|
||||
|
||||
it('does not rewrite isPlaybackBuffering when the flag is unchanged', () => {
|
||||
const track = makeTrack({ duration: 100 });
|
||||
usePlayerStore.setState({
|
||||
currentTrack: track,
|
||||
isPlaying: true,
|
||||
isPlaybackBuffering: true,
|
||||
});
|
||||
const setStateSpy = vi.spyOn(usePlayerStore, 'setState');
|
||||
|
||||
emitTauriEvent('audio:progress', {
|
||||
current_time: 0,
|
||||
duration: 100,
|
||||
buffering: true,
|
||||
});
|
||||
vi.advanceTimersByTime(2000);
|
||||
emitTauriEvent('audio:progress', {
|
||||
current_time: 0.9,
|
||||
duration: 100,
|
||||
buffering: true,
|
||||
});
|
||||
|
||||
const bufferingWrites = setStateSpy.mock.calls.filter(
|
||||
call => typeof call[0] === 'object' && call[0] !== null && 'isPlaybackBuffering' in call[0],
|
||||
);
|
||||
expect(bufferingWrites).toHaveLength(0);
|
||||
|
||||
setStateSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
queueServerId: null,
|
||||
queueIndex: 0,
|
||||
isPlaying: false,
|
||||
isPlaybackBuffering: false,
|
||||
progress: 0,
|
||||
buffered: 0,
|
||||
currentTime: 0,
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface PlayerState {
|
||||
queueServerId: string | null;
|
||||
queueIndex: number;
|
||||
isPlaying: boolean;
|
||||
/** HTTP stream still buffering (network / demux probe) — show loading on cover art. */
|
||||
isPlaybackBuffering: boolean;
|
||||
progress: number; // 0–1
|
||||
buffered: number; // 0–1 (unused in Rust backend, kept for UI compat)
|
||||
currentTime: number;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
@import './modal.css';
|
||||
@import './playback-delay-sleep-delayed-start-modal.css';
|
||||
@import './lucky-mix-pips-shared-by-the-inline-queue-lucky-cube-indicator.css';
|
||||
@import './playback-buffering-overlay.css';
|
||||
@import './playlist-edit-modal.css';
|
||||
@import './artists-page.css';
|
||||
@import './years-page.css';
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Cover art while `isPlaybackBuffering` (player bar + queue current track). */
|
||||
.player-album-art-wrap.playback-buffering .player-album-art,
|
||||
.player-album-art-wrap.playback-buffering .player-album-art-placeholder {
|
||||
filter: grayscale(1);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.queue-current-cover.playback-buffering img,
|
||||
.queue-current-cover.playback-buffering .fallback {
|
||||
filter: grayscale(1);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.playback-buffering-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.playback-buffering-overlay__icon {
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
|
||||
.player-album-art-wrap .playback-buffering-overlay {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
Reference in New Issue
Block a user