mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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>
|
||||
|
||||
Reference in New Issue
Block a user