mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
fix(preview): sync audio start, ring animation, and download timeout (#423)
* fix(preview): sync audio start, ring animation, and download timeout Three coupled fixes for the track-preview engine: 1. Audio sync. `Sink::try_seek` was running on a worker thread after `sink.append(source)`, so the sink began playing position 0 while the seek was still iterating to the mid-track target. With the 30 s `take_duration` cap counting wall-clock from append, audio could only become audible ~25% into the preview window. The seek now runs on the bare source before append, then `take_duration` wraps it — playback starts at the seek position with the cap measured from there. 2. Ring animation gating. The CSS progress-ring animation was bound to `is-previewing` (set on click), so the ring sprinted ahead of any download/decode/seek warmup and didn't reset cleanly when switching from one preview to another. Added an `audioStarted` flag in `previewStore` that flips on `audio:preview-start` from the engine; CSS animation is now gated on `audio-started` instead. `is-previewing` still drives tooltip/icon for instant click feedback. Same SVG is reused for a 25%-arc rotating loading spinner while waiting for audio, with a 150 ms delay so cached/short previews don't flash. 3. Download timeout. The shared `audio_http_client` caps at 30 s, which aborts mid-download on multi-hundred-MB uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB). The preview engine now builds a dedicated client with a 5 min timeout for the bytes fetch. Watchdog still bounds the playback window at 30 s once the audio actually starts. Touches `audio/preview.rs`, `previewStore.ts`, `components.css` plus the eight tracklist/player-bar components that render the preview button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(changelog): add preview audio sync fix for PR #423 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
9cc74a7f88
commit
297c9f1125
@@ -113,6 +113,8 @@ The drag ghost shows a **trash** affordance only while the cursor is outside the
|
|||||||
|
|
||||||
- **Polish** *(PR [#397](https://github.com/Psychotoxical/psysonic/pull/397), by [@cucadmuh](https://github.com/cucadmuh))*: multiple branch-local interaction fixes around sidebar drag/drop behavior, Live dropdown layering, queue-resize handle behavior during scroll/overlay-scrollbar interaction, and now-playing narrow-layout stability.
|
- **Polish** *(PR [#397](https://github.com/Psychotoxical/psysonic/pull/397), by [@cucadmuh](https://github.com/cucadmuh))*: multiple branch-local interaction fixes around sidebar drag/drop behavior, Live dropdown layering, queue-resize handle behavior during scroll/overlay-scrollbar interaction, and now-playing narrow-layout stability.
|
||||||
|
|
||||||
|
- **Track preview audio in sync with progress ring; huge files no longer abort** *(Issue [#421](https://github.com/Psychotoxical/psysonic/issues/421), PR [#423](https://github.com/Psychotoxical/psysonic/pull/423), by [@Psychotoxical](https://github.com/Psychotoxical))*: Previews used to start audio about 25 % into the preview window on mid-track starts because `Sink::try_seek` ran in parallel with `sink.append` while the 30 s `take_duration` cap was already counting wall-clock from append. The seek now runs on the bare source before append, and the progress-ring animation only starts once the engine actually emits `audio:preview-start` — a small loading spinner is shown during the download/decode/seek warmup. The preview HTTP-client timeout was raised from 30 s to 5 min, so multi-hundred-megabyte Hi-Res files no longer abort the download mid-fetch.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [1.44.0] - 2026-04-29
|
## [1.44.0] - 2026-04-29
|
||||||
|
|||||||
@@ -133,7 +133,19 @@ pub async fn audio_preview_play(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Download ─────────────────────────────────────────────────────────────
|
// ── Download ─────────────────────────────────────────────────────────────
|
||||||
let bytes = audio_http_client(&state)
|
// Dedicated client with a generous timeout. The shared `audio_http_client`
|
||||||
|
// caps at 30 s, which aborts mid-download on multi-hundred-megabyte
|
||||||
|
// uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need
|
||||||
|
// ~60–120 s on a typical home LAN. The watchdog (30 s wall-clock) still
|
||||||
|
// bounds how long the preview plays once the bytes are in memory, so a
|
||||||
|
// long download just means a longer "loading" spinner before audio starts.
|
||||||
|
let preview_http = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(300))
|
||||||
|
.use_rustls_tls()
|
||||||
|
.user_agent(crate::subsonic_wire_user_agent())
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| audio_http_client(&state));
|
||||||
|
let bytes = preview_http
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -163,12 +175,20 @@ pub async fn audio_preview_play(
|
|||||||
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
|
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||||
|
|
||||||
// ── Build source pipeline ────────────────────────────────────────────────
|
// ── Build source pipeline ────────────────────────────────────────────────
|
||||||
// Minimal pipeline: f32 conversion + take_duration cap. No EQ, no crossfade,
|
// Seek FIRST on the bare decoder, THEN cap with take_duration. Capping
|
||||||
// no ReplayGain — preview is intentionally simple. Volume is set on the Sink.
|
// before the seek made take_duration's wall-clock counter tick from
|
||||||
|
// sink.append() while try_seek was still iterating the decoder to
|
||||||
|
// mid-track — the preview window consumed itself before audio actually
|
||||||
|
// arrived at the speaker (~25% of duration silent on FLAC/MP3 mid-track
|
||||||
|
// starts). Symphonia FLAC without SEEKTABLE may fail try_seek; preview
|
||||||
|
// then plays from 0, which is acceptable.
|
||||||
|
// No EQ / no crossfade / no ReplayGain — preview stays simple.
|
||||||
|
let mut source = decoder.convert_samples::<f32>();
|
||||||
|
if start_sec > 0.5 {
|
||||||
|
let _ = source.try_seek(Duration::from_secs_f64(start_sec));
|
||||||
|
}
|
||||||
let dur = Duration::from_secs_f64(duration_sec.max(1.0).min(120.0));
|
let dur = Duration::from_secs_f64(duration_sec.max(1.0).min(120.0));
|
||||||
let source = decoder
|
let source = source.take_duration(dur);
|
||||||
.convert_samples::<f32>()
|
|
||||||
.take_duration(dur);
|
|
||||||
|
|
||||||
// ── Build secondary sink on the existing OutputStream ────────────────────
|
// ── Build secondary sink on the existing OutputStream ────────────────────
|
||||||
let sink = Arc::new(
|
let sink = Arc::new(
|
||||||
@@ -178,20 +198,6 @@ pub async fn audio_preview_play(
|
|||||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||||
sink.append(source);
|
sink.append(source);
|
||||||
|
|
||||||
// ── Best-effort seek to mid-track start position ─────────────────────────
|
|
||||||
// Symphonia FLAC without SEEKTABLE may fail here; preview then plays from 0
|
|
||||||
// which is acceptable. Hard timeout 800 ms via a worker thread.
|
|
||||||
if start_sec > 0.5 {
|
|
||||||
let start_dur = Duration::from_secs_f64(start_sec);
|
|
||||||
let seek_sink = sink.clone();
|
|
||||||
let (tx, rx) = std::sync::mpsc::channel::<()>();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
seek_sink.try_seek(start_dur).ok();
|
|
||||||
let _ = tx.send(());
|
|
||||||
});
|
|
||||||
let _ = rx.recv_timeout(Duration::from_millis(800));
|
|
||||||
}
|
|
||||||
|
|
||||||
*state.preview_sink.lock().unwrap() = Some(sink.clone());
|
*state.preview_sink.lock().unwrap() = Some(sink.clone());
|
||||||
*state.preview_song_id.lock().unwrap() = Some(id.clone());
|
*state.preview_song_id.lock().unwrap() = Some(id.clone());
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ const TrackRow = React.memo(function TrackRow({
|
|||||||
const isActive = currentTrackId === song.id;
|
const isActive = currentTrackId === song.id;
|
||||||
// Primitive selector: row only re-renders when *this song's* preview state flips.
|
// Primitive selector: row only re-renders when *this song's* preview state flips.
|
||||||
const isPreviewing = usePreviewStore(s => s.previewingId === song.id);
|
const isPreviewing = usePreviewStore(s => s.previewingId === song.id);
|
||||||
|
const isPreviewAudioStarted = usePreviewStore(s => s.previewingId === song.id && s.audioStarted);
|
||||||
|
|
||||||
const renderCell = (colDef: ColDef) => {
|
const renderCell = (colDef: ColDef) => {
|
||||||
const key = colDef.key as ColKey;
|
const key = colDef.key as ColKey;
|
||||||
@@ -156,7 +157,7 @@ const TrackRow = React.memo(function TrackRow({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewAudioStarted ? ' audio-started' : ''}`}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'albums');
|
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'albums');
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ export default function PlayerBar() {
|
|||||||
const playSlotRef = useRef<HTMLSpanElement>(null);
|
const playSlotRef = useRef<HTMLSpanElement>(null);
|
||||||
const scheduleRemaining = usePlaybackScheduleRemaining();
|
const scheduleRemaining = usePlaybackScheduleRemaining();
|
||||||
const isPreviewing = usePreviewStore(s => s.previewingId !== null);
|
const isPreviewing = usePreviewStore(s => s.previewingId !== null);
|
||||||
|
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
|
||||||
const previewingTrack = usePreviewStore(s => s.previewingTrack);
|
const previewingTrack = usePreviewStore(s => s.previewingTrack);
|
||||||
|
|
||||||
const isRadio = !!currentRadio;
|
const isRadio = !!currentRadio;
|
||||||
@@ -312,7 +313,7 @@ export default function PlayerBar() {
|
|||||||
<>
|
<>
|
||||||
<footer
|
<footer
|
||||||
ref={playerBarRef}
|
ref={playerBarRef}
|
||||||
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}${showPreviewMeta ? ' is-previewing' : ''}`}
|
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}${showPreviewMeta ? ' is-previewing' : ''}${showPreviewMeta && previewAudioStarted ? ' audio-started' : ''}`}
|
||||||
style={floatingPlayerBar ? floatingStyle : undefined}
|
style={floatingPlayerBar ? floatingStyle : undefined}
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={t('player.regionLabel')}
|
aria-label={t('player.regionLabel')}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export default function ArtistDetail() {
|
|||||||
const currentTrack = usePlayerStore(state => state.currentTrack);
|
const currentTrack = usePlayerStore(state => state.currentTrack);
|
||||||
const isPlaying = usePlayerStore(state => state.isPlaying);
|
const isPlaying = usePlayerStore(state => state.isPlaying);
|
||||||
const previewingId = usePreviewStore(s => s.previewingId);
|
const previewingId = usePreviewStore(s => s.previewingId);
|
||||||
|
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
|
||||||
const downloadArtist = useOfflineStore(s => s.downloadArtist);
|
const downloadArtist = useOfflineStore(s => s.downloadArtist);
|
||||||
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
|
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||||
@@ -737,7 +738,7 @@ export default function ArtistDetail() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'artist'); }}
|
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'artist'); }}
|
||||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export default function Favorites() {
|
|||||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||||
const previewingId = usePreviewStore(s => s.previewingId);
|
const previewingId = usePreviewStore(s => s.previewingId);
|
||||||
|
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
|
||||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||||
@@ -693,7 +694,7 @@ export default function Favorites() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'favorites'); }}
|
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'favorites'); }}
|
||||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ export default function PlaylistDetail() {
|
|||||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||||
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
||||||
const previewingId = usePreviewStore(s => s.previewingId);
|
const previewingId = usePreviewStore(s => s.previewingId);
|
||||||
|
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
|
||||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||||
const zipDownloads = useZipDownloadStore(s => s.downloads);
|
const zipDownloads = useZipDownloadStore(s => s.downloads);
|
||||||
@@ -1709,7 +1710,7 @@ export default function PlaylistDetail() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'playlists');
|
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'playlists');
|
||||||
@@ -1856,7 +1857,7 @@ export default function PlaylistDetail() {
|
|||||||
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
|
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||||
onClick={e => { e.stopPropagation(); startPreview(song); }}
|
onClick={e => { e.stopPropagation(); startPreview(song); }}
|
||||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export default function RandomMix() {
|
|||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||||
const previewingId = usePreviewStore(s => s.previewingId);
|
const previewingId = usePreviewStore(s => s.previewingId);
|
||||||
|
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
|
||||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||||
@@ -470,7 +471,7 @@ export default function RandomMix() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'randomMix'); }}
|
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'randomMix'); }}
|
||||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
@@ -603,7 +604,7 @@ export default function RandomMix() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'randomMix'); }}
|
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'randomMix'); }}
|
||||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ interface PreviewState {
|
|||||||
elapsed: number;
|
elapsed: number;
|
||||||
/** Total preview window in seconds (echoes the duration_sec arg). */
|
/** Total preview window in seconds (echoes the duration_sec arg). */
|
||||||
duration: number;
|
duration: number;
|
||||||
|
/**
|
||||||
|
* True only after the engine has emitted `audio:preview-start` for the
|
||||||
|
* current `previewingId` — i.e. audio is actually playing. Drives the
|
||||||
|
* progress-ring animation so the ring doesn't run ahead of the speaker
|
||||||
|
* during the engine's download/decode/seek warmup. Reset to false on every
|
||||||
|
* `startPreview` call so a switch from track A to track B doesn't carry
|
||||||
|
* over A's animation state.
|
||||||
|
*/
|
||||||
|
audioStarted: boolean;
|
||||||
|
|
||||||
startPreview: (song: { id: string; title: string; artist: string; coverArt?: string; duration?: number }, location: TrackPreviewLocation) => Promise<void>;
|
startPreview: (song: { id: string; title: string; artist: string; coverArt?: string; duration?: number }, location: TrackPreviewLocation) => Promise<void>;
|
||||||
stopPreview: () => Promise<void>;
|
stopPreview: () => Promise<void>;
|
||||||
@@ -40,6 +49,7 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
|||||||
previewingTrack: null,
|
previewingTrack: null,
|
||||||
elapsed: 0,
|
elapsed: 0,
|
||||||
duration: 30,
|
duration: 30,
|
||||||
|
audioStarted: false,
|
||||||
|
|
||||||
startPreview: async (song, location) => {
|
startPreview: async (song, location) => {
|
||||||
const auth = useAuthStore.getState();
|
const auth = useAuthStore.getState();
|
||||||
@@ -77,6 +87,7 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
|||||||
previewingTrack: { id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt },
|
previewingTrack: { id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt },
|
||||||
elapsed: 0,
|
elapsed: 0,
|
||||||
duration: previewDuration,
|
duration: previewDuration,
|
||||||
|
audioStarted: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -90,7 +101,7 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Roll back optimistic state on failure.
|
// Roll back optimistic state on failure.
|
||||||
if (get().previewingId === song.id) {
|
if (get().previewingId === song.id) {
|
||||||
set({ previewingId: null, previewingTrack: null, elapsed: 0 });
|
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@@ -102,15 +113,19 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
|||||||
await invoke('audio_preview_stop');
|
await invoke('audio_preview_stop');
|
||||||
} catch {
|
} catch {
|
||||||
/* engine will emit preview-end anyway; clear locally as fallback */
|
/* engine will emit preview-end anyway; clear locally as fallback */
|
||||||
set({ previewingId: null, previewingTrack: null, elapsed: 0 });
|
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_onStart: (id) => {
|
_onStart: (id) => {
|
||||||
if (get().previewingId !== id) {
|
const current = get().previewingId;
|
||||||
|
if (current !== id) {
|
||||||
// Engine fired start for an id we didn't track locally — keep id but
|
// Engine fired start for an id we didn't track locally — keep id but
|
||||||
// leave previewingTrack as-is (the caller's startPreview() set it).
|
// leave previewingTrack as-is (the caller's startPreview() set it).
|
||||||
set({ previewingId: id, elapsed: 0 });
|
set({ previewingId: id, elapsed: 0, audioStarted: true });
|
||||||
|
} else {
|
||||||
|
// Audio is now actually playing — unblock the progress-ring animation.
|
||||||
|
set({ audioStarted: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -121,6 +136,6 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
|||||||
|
|
||||||
_onEnd: (id) => {
|
_onEnd: (id) => {
|
||||||
if (get().previewingId !== id) return;
|
if (get().previewingId !== id) return;
|
||||||
set({ previewingId: null, previewingTrack: null, elapsed: 0 });
|
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1866,7 +1866,27 @@
|
|||||||
transform: translateX(0.5px);
|
transform: translateX(0.5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-suggestion-preview-btn.is-previewing .playlist-suggestion-preview-ring-progress {
|
/* Loading state: the engine is still downloading/decoding/seeking. A short
|
||||||
|
rotating arc (~25% of the circumference) signals "pending" without
|
||||||
|
pretending to be progress. 150 ms delay suppresses the spinner for
|
||||||
|
small/cached files where audio starts almost instantly. */
|
||||||
|
.playlist-suggestion-preview-btn.is-previewing:not(.audio-started) .playlist-suggestion-preview-ring-progress {
|
||||||
|
/* circumference = 2π × 10.5 ≈ 65.97 → 25% arc + 75% gap */
|
||||||
|
stroke-dasharray: 16.5 49.5;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
animation: playlist-preview-loading 1.1s linear infinite;
|
||||||
|
animation-delay: 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes playlist-preview-loading {
|
||||||
|
from { stroke-dashoffset: 0; }
|
||||||
|
to { stroke-dashoffset: -65.97; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation runs only after `audio-started` is added — i.e. once the engine
|
||||||
|
emitted `audio:preview-start` for this track. Prevents the ring from
|
||||||
|
sprinting ahead of audio during the engine's download/decode/seek warmup. */
|
||||||
|
.playlist-suggestion-preview-btn.audio-started .playlist-suggestion-preview-ring-progress {
|
||||||
animation: playlist-preview-progress var(--preview-duration, 30s) linear forwards;
|
animation: playlist-preview-progress var(--preview-duration, 30s) linear forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2487,6 +2507,27 @@ html[data-track-previews-randommix="off"] [data-preview-loc="randomMix"] .pl
|
|||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-dasharray: 100;
|
stroke-dasharray: 100;
|
||||||
stroke-dashoffset: 100;
|
stroke-dashoffset: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner — same logic as the tracklist ring above. The SVG uses
|
||||||
|
pathLength="100", so the circumference is normalised: 25 unit arc + 75
|
||||||
|
unit gap, with the offset rolling from 0 to -100. */
|
||||||
|
.player-bar.is-previewing:not(.audio-started) .player-btn-preview-ring-progress {
|
||||||
|
stroke-dasharray: 25 75;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
animation: player-preview-loading 1.1s linear infinite;
|
||||||
|
animation-delay: 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes player-preview-loading {
|
||||||
|
from { stroke-dashoffset: 0; }
|
||||||
|
to { stroke-dashoffset: -100; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation gated on the player-bar `audio-started` flag (set after the
|
||||||
|
engine emits `audio:preview-start`). Same rationale as the tracklist
|
||||||
|
preview ring above. */
|
||||||
|
.player-bar.audio-started .player-btn-preview-ring-progress {
|
||||||
animation: player-preview-progress var(--preview-duration, 30s) linear forwards;
|
animation: player-preview-progress var(--preview-duration, 30s) linear forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user