mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
Merge pull request #201 from cucadmuh/feat/playback-source-indicator
feat(queue): playback source badge + preload-aware source tracking
This commit is contained in:
+19
-11
@@ -1200,11 +1200,15 @@ impl SizedDecoder {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a decoder from any `MediaSource` (e.g. `RadioBuffer`).
|
/// Build a decoder from any `MediaSource` (e.g. track-stream or radio).
|
||||||
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
|
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
|
||||||
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
|
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
|
||||||
fn new_streaming(media: Box<dyn MediaSource>, format_hint: Option<&str>) -> Result<Self, String> {
|
fn new_streaming(
|
||||||
// Larger read-ahead buffer for the live radio SPSC consumer — reduces
|
media: Box<dyn MediaSource>,
|
||||||
|
format_hint: Option<&str>,
|
||||||
|
source_tag: &str,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
// Larger read-ahead buffer for the live streaming SPSC consumer — reduces
|
||||||
// read() call frequency into the ring buffer, easing I/O spikes.
|
// read() call frequency into the ring buffer, easing I/O spikes.
|
||||||
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: 512 * 1024 });
|
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: 512 * 1024 });
|
||||||
let mut hint = Hint::new();
|
let mut hint = Hint::new();
|
||||||
@@ -1212,16 +1216,16 @@ impl SizedDecoder {
|
|||||||
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
|
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
|
||||||
let probed = symphonia::default::get_probe()
|
let probed = symphonia::default::get_probe()
|
||||||
.format(&hint, mss, &format_opts, &MetadataOptions::default())
|
.format(&hint, mss, &format_opts, &MetadataOptions::default())
|
||||||
.map_err(|e| format!("radio: format probe failed: {e}"))?;
|
.map_err(|e| format!("{source_tag}: format probe failed: {e}"))?;
|
||||||
|
|
||||||
let track = probed.format.tracks().iter()
|
let track = probed.format.tracks().iter()
|
||||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||||
.ok_or_else(|| "radio: no audio track found".to_string())?;
|
.ok_or_else(|| format!("{source_tag}: no audio track found"))?;
|
||||||
let track_id = track.id;
|
let track_id = track.id;
|
||||||
// Live streams have no known total frame count → total_duration = None.
|
// Live streams have no known total frame count → total_duration = None.
|
||||||
let total_duration = None;
|
let total_duration = None;
|
||||||
let mut decoder = try_make_radio_decoder(&track.codec_params, &DecoderOptions::default())
|
let mut decoder = try_make_radio_decoder(&track.codec_params, &DecoderOptions::default())
|
||||||
.map_err(|e| format!("radio: codec init failed: {e}"))?;
|
.map_err(|e| format!("{source_tag}: codec init failed: {e}"))?;
|
||||||
let mut format = probed.format;
|
let mut format = probed.format;
|
||||||
|
|
||||||
let mut errors = 0usize;
|
let mut errors = 0usize;
|
||||||
@@ -1235,12 +1239,12 @@ impl SizedDecoder {
|
|||||||
Ok(d) => break d,
|
Ok(d) => break d,
|
||||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||||
errors += 1;
|
errors += 1;
|
||||||
eprintln!("[psysonic] radio init: dropped corrupt frame #{errors}: {msg}");
|
eprintln!("[psysonic] {source_tag} init: dropped corrupt frame #{errors}: {msg}");
|
||||||
if errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
if errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||||||
return Err("radio: too many consecutive decode errors".into());
|
return Err(format!("{source_tag}: too many consecutive decode errors"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => return Err(format!("radio: decode error: {e}")),
|
Err(e) => return Err(format!("{source_tag}: decode error: {e}")),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let spec = decoded.spec().to_owned();
|
let spec = decoded.spec().to_owned();
|
||||||
@@ -2349,7 +2353,7 @@ pub async fn audio_play(
|
|||||||
),
|
),
|
||||||
PlayInput::Streaming { reader, format_hint } => {
|
PlayInput::Streaming { reader, format_hint } => {
|
||||||
let decoder = tokio::task::spawn_blocking(move || {
|
let decoder = tokio::task::spawn_blocking(move || {
|
||||||
SizedDecoder::new_streaming(Box::new(reader), format_hint.as_deref())
|
SizedDecoder::new_streaming(Box::new(reader), format_hint.as_deref(), "track-stream")
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())??;
|
.map_err(|e| e.to_string())??;
|
||||||
@@ -3076,11 +3080,13 @@ pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State
|
|||||||
pub async fn audio_preload(
|
pub async fn audio_preload(
|
||||||
url: String,
|
url: String,
|
||||||
duration_hint: f64,
|
duration_hint: f64,
|
||||||
|
app: AppHandle,
|
||||||
state: State<'_, AudioEngine>,
|
state: State<'_, AudioEngine>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
{
|
{
|
||||||
let preloaded = state.preloaded.lock().unwrap();
|
let preloaded = state.preloaded.lock().unwrap();
|
||||||
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
|
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
|
||||||
|
let _ = app.emit("audio:preload-ready", url.clone());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3102,7 +3108,9 @@ pub async fn audio_preload(
|
|||||||
response.bytes().await.map_err(|e| e.to_string())?.into()
|
response.bytes().await.map_err(|e| e.to_string())?.into()
|
||||||
};
|
};
|
||||||
let _ = duration_hint; // kept in API for compatibility
|
let _ = duration_hint; // kept in API for compatibility
|
||||||
|
let url_for_emit = url.clone();
|
||||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
|
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||||
|
let _ = app.emit("audio:preload-ready", url_for_emit);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3201,7 +3209,7 @@ pub async fn audio_play_radio(
|
|||||||
|
|
||||||
let hint_clone = fmt_hint.clone();
|
let hint_clone = fmt_hint.clone();
|
||||||
let decoder = tokio::task::spawn_blocking(move || {
|
let decoder = tokio::task::spawn_blocking(move || {
|
||||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref())
|
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio")
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())??;
|
.map_err(|e| e.to_string())??;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef, useMemo } from 'react';
|
import React, { useState, useRef, useMemo } from 'react';
|
||||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio } from 'lucide-react';
|
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive } from 'lucide-react';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||||
import { usePlaylistStore } from '../store/playlistStore';
|
import { usePlaylistStore } from '../store/playlistStore';
|
||||||
import { useCachedUrl } from './CachedImage';
|
import { useCachedUrl } from './CachedImage';
|
||||||
@@ -251,6 +251,8 @@ export default function QueuePanel() {
|
|||||||
const enqueueAt = usePlayerStore(s => s.enqueueAt);
|
const enqueueAt = usePlayerStore(s => s.enqueueAt);
|
||||||
const contextMenu = usePlayerStore(s => s.contextMenu);
|
const contextMenu = usePlayerStore(s => s.contextMenu);
|
||||||
|
|
||||||
|
const playbackSource = usePlayerStore(s => s.currentPlaybackSource);
|
||||||
|
|
||||||
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
||||||
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
||||||
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
|
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
|
||||||
@@ -450,8 +452,29 @@ export default function QueuePanel() {
|
|||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
const rgParts = formatQueueReplayGainParts(currentTrack, t);
|
const rgParts = formatQueueReplayGainParts(currentTrack, t);
|
||||||
const techLine = [...baseParts, ...rgParts].join(' · ');
|
const techLine = [...baseParts, ...rgParts].join(' · ');
|
||||||
if (!techLine) return null;
|
if (!techLine && !playbackSource) return null;
|
||||||
return <div className="queue-current-tech">{techLine}</div>;
|
return (
|
||||||
|
<div className="queue-current-tech">
|
||||||
|
{playbackSource && (
|
||||||
|
<span
|
||||||
|
className="queue-current-tech-source"
|
||||||
|
data-tooltip={
|
||||||
|
playbackSource === 'offline'
|
||||||
|
? t('queue.sourceOffline')
|
||||||
|
: playbackSource === 'hot'
|
||||||
|
? t('queue.sourceHot')
|
||||||
|
: t('queue.sourceStream')
|
||||||
|
}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{playbackSource === 'offline' && <FolderOpen size={11} strokeWidth={2.25} />}
|
||||||
|
{playbackSource === 'hot' && <HardDrive size={11} strokeWidth={2.25} />}
|
||||||
|
{playbackSource === 'stream' && <Waves size={11} strokeWidth={2.25} />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="queue-current-tech-main">{techLine}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})()}
|
})()}
|
||||||
<div className="queue-current-track-body">
|
<div className="queue-current-track-body">
|
||||||
<div className="queue-current-cover">
|
<div className="queue-current-cover">
|
||||||
|
|||||||
@@ -858,6 +858,9 @@ export const deTranslation = {
|
|||||||
rgTrack: 'T {{db}} dB',
|
rgTrack: 'T {{db}} dB',
|
||||||
rgAlbum: 'A {{db}} dB',
|
rgAlbum: 'A {{db}} dB',
|
||||||
rgPeak: 'Peak {{pk}}',
|
rgPeak: 'Peak {{pk}}',
|
||||||
|
sourceOffline: 'Wiedergabe aus der Offline-Bibliothek',
|
||||||
|
sourceHot: 'Wiedergabe aus dem Cache',
|
||||||
|
sourceStream: 'Wiedergabe aus dem Netzwerkstream',
|
||||||
},
|
},
|
||||||
statistics: {
|
statistics: {
|
||||||
title: 'Statistiken',
|
title: 'Statistiken',
|
||||||
|
|||||||
@@ -860,6 +860,9 @@ export const enTranslation = {
|
|||||||
rgTrack: 'T {{db}} dB',
|
rgTrack: 'T {{db}} dB',
|
||||||
rgAlbum: 'A {{db}} dB',
|
rgAlbum: 'A {{db}} dB',
|
||||||
rgPeak: 'Peak {{pk}}',
|
rgPeak: 'Peak {{pk}}',
|
||||||
|
sourceOffline: 'Playing from offline library',
|
||||||
|
sourceHot: 'Playing from cache',
|
||||||
|
sourceStream: 'Playing from network stream',
|
||||||
},
|
},
|
||||||
statistics: {
|
statistics: {
|
||||||
title: 'Statistics',
|
title: 'Statistics',
|
||||||
|
|||||||
@@ -861,6 +861,9 @@ export const esTranslation = {
|
|||||||
rgTrack: 'T {{db}} dB',
|
rgTrack: 'T {{db}} dB',
|
||||||
rgAlbum: 'A {{db}} dB',
|
rgAlbum: 'A {{db}} dB',
|
||||||
rgPeak: 'Pico {{pk}}',
|
rgPeak: 'Pico {{pk}}',
|
||||||
|
sourceOffline: 'Reproducción desde la biblioteca sin conexión',
|
||||||
|
sourceHot: 'Reproducción desde la caché',
|
||||||
|
sourceStream: 'Reproducción desde la transmisión en red',
|
||||||
},
|
},
|
||||||
statistics: {
|
statistics: {
|
||||||
title: 'Estadísticas',
|
title: 'Estadísticas',
|
||||||
|
|||||||
@@ -856,6 +856,9 @@ export const frTranslation = {
|
|||||||
rgTrack: 'T {{db}} dB',
|
rgTrack: 'T {{db}} dB',
|
||||||
rgAlbum: 'A {{db}} dB',
|
rgAlbum: 'A {{db}} dB',
|
||||||
rgPeak: 'Pic {{pk}}',
|
rgPeak: 'Pic {{pk}}',
|
||||||
|
sourceOffline: 'Lecture depuis la bibliothèque hors ligne',
|
||||||
|
sourceHot: 'Lecture depuis le cache',
|
||||||
|
sourceStream: 'Lecture depuis le flux réseau',
|
||||||
},
|
},
|
||||||
statistics: {
|
statistics: {
|
||||||
title: 'Statistiques',
|
title: 'Statistiques',
|
||||||
|
|||||||
@@ -855,6 +855,9 @@ export const nbTranslation = {
|
|||||||
rgTrack: 'T {{db}} dB',
|
rgTrack: 'T {{db}} dB',
|
||||||
rgAlbum: 'A {{db}} dB',
|
rgAlbum: 'A {{db}} dB',
|
||||||
rgPeak: 'Topp {{pk}}',
|
rgPeak: 'Topp {{pk}}',
|
||||||
|
sourceOffline: 'Spiller fra offlinebibliotek',
|
||||||
|
sourceHot: 'Spiller fra cache',
|
||||||
|
sourceStream: 'Spiller fra nettverksstrøm',
|
||||||
},
|
},
|
||||||
statistics: {
|
statistics: {
|
||||||
title: 'Statistikk',
|
title: 'Statistikk',
|
||||||
|
|||||||
@@ -855,6 +855,9 @@ export const nlTranslation = {
|
|||||||
rgTrack: 'T {{db}} dB',
|
rgTrack: 'T {{db}} dB',
|
||||||
rgAlbum: 'A {{db}} dB',
|
rgAlbum: 'A {{db}} dB',
|
||||||
rgPeak: 'Piek {{pk}}',
|
rgPeak: 'Piek {{pk}}',
|
||||||
|
sourceOffline: 'Afspelen vanuit offlinebibliotheek',
|
||||||
|
sourceHot: 'Afspelen vanuit cache',
|
||||||
|
sourceStream: 'Afspelen vanuit netwerkstream',
|
||||||
},
|
},
|
||||||
statistics: {
|
statistics: {
|
||||||
title: 'Statistieken',
|
title: 'Statistieken',
|
||||||
|
|||||||
@@ -906,6 +906,9 @@ export const ruTranslation = {
|
|||||||
rgTrack: 'Т {{db}} дБ',
|
rgTrack: 'Т {{db}} дБ',
|
||||||
rgAlbum: 'А {{db}} дБ',
|
rgAlbum: 'А {{db}} дБ',
|
||||||
rgPeak: 'Пик {{pk}}',
|
rgPeak: 'Пик {{pk}}',
|
||||||
|
sourceOffline: 'Играет из офлайн-библиотеки',
|
||||||
|
sourceHot: 'Играет из кэша',
|
||||||
|
sourceStream: 'Играет из сетевого потока',
|
||||||
},
|
},
|
||||||
statistics: {
|
statistics: {
|
||||||
title: 'Статистика',
|
title: 'Статистика',
|
||||||
|
|||||||
@@ -851,6 +851,9 @@ export const zhTranslation = {
|
|||||||
rgTrack: '曲目 {{db}} dB',
|
rgTrack: '曲目 {{db}} dB',
|
||||||
rgAlbum: '专辑 {{db}} dB',
|
rgAlbum: '专辑 {{db}} dB',
|
||||||
rgPeak: '峰值 {{pk}}',
|
rgPeak: '峰值 {{pk}}',
|
||||||
|
sourceOffline: '正在从离线库播放',
|
||||||
|
sourceHot: '正在从缓存播放',
|
||||||
|
sourceStream: '正在从网络流播放',
|
||||||
},
|
},
|
||||||
statistics: {
|
statistics: {
|
||||||
title: '统计',
|
title: '统计',
|
||||||
|
|||||||
+81
-13
@@ -4,7 +4,7 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic';
|
import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic';
|
||||||
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
|
import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl';
|
||||||
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
||||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
@@ -113,6 +113,13 @@ async function buildInfiniteQueueCandidates(
|
|||||||
interface PlayerState {
|
interface PlayerState {
|
||||||
currentTrack: Track | null;
|
currentTrack: Track | null;
|
||||||
currentRadio: InternetRadioStation | null;
|
currentRadio: InternetRadioStation | null;
|
||||||
|
/** Latches the source used to start the currently playing track. */
|
||||||
|
currentPlaybackSource: PlaybackSourceKind | null;
|
||||||
|
/**
|
||||||
|
* Subsonic track id for which `audio_preload` finished into the engine RAM slot (see `audio:preload-ready`).
|
||||||
|
* Cleared after a successful `audio_play` consumed that preload, or when starting another track.
|
||||||
|
*/
|
||||||
|
enginePreloadedTrackId: string | null;
|
||||||
queue: Track[];
|
queue: Track[];
|
||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -465,6 +472,16 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
// Byte pre-download — runs early so bytes are cached by chain time.
|
// Byte pre-download — runs early so bytes are cached by chain time.
|
||||||
if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== bytePreloadingId) {
|
if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== bytePreloadingId) {
|
||||||
bytePreloadingId = nextTrack.id;
|
bytePreloadingId = nextTrack.id;
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.info('[psysonic][preload-request]', {
|
||||||
|
nextTrackId: nextTrack.id,
|
||||||
|
nextUrl,
|
||||||
|
shouldBytePreload,
|
||||||
|
shouldBytePreloadForGaplessBackup,
|
||||||
|
remaining,
|
||||||
|
gaplessEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,6 +644,20 @@ export function initAudioListeners(): () => void {
|
|||||||
listen<void>('audio:ended', () => handleAudioEnded()),
|
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||||
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
|
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
|
||||||
|
listen<string>('audio:preload-ready', ({ payload }) => {
|
||||||
|
const tid = streamUrlTrackId(payload);
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.info('[psysonic][preload-ready]', {
|
||||||
|
payload,
|
||||||
|
parsedTrackId: tid,
|
||||||
|
prevEnginePreloadedTrackId: usePlayerStore.getState().enginePreloadedTrackId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (tid) usePlayerStore.setState({ enginePreloadedTrackId: tid });
|
||||||
|
else if (import.meta.env.DEV) {
|
||||||
|
console.warn('[psysonic][preload-ready] could not parse track id from payload URL');
|
||||||
|
}
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sync Last.fm loved tracks cache on startup.
|
// Sync Last.fm loved tracks cache on startup.
|
||||||
@@ -823,6 +854,8 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
currentTrack: null,
|
currentTrack: null,
|
||||||
currentRadio: null,
|
currentRadio: null,
|
||||||
|
currentPlaybackSource: null,
|
||||||
|
enginePreloadedTrackId: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
@@ -934,7 +967,15 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
}
|
}
|
||||||
isAudioPaused = false;
|
isAudioPaused = false;
|
||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, currentRadio: null });
|
set({
|
||||||
|
isPlaying: false,
|
||||||
|
progress: 0,
|
||||||
|
buffered: 0,
|
||||||
|
currentTime: 0,
|
||||||
|
currentRadio: null,
|
||||||
|
currentPlaybackSource: null,
|
||||||
|
enginePreloadedTrackId: null,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── playRadio ────────────────────────────────────────────────────────────
|
// ── playRadio ────────────────────────────────────────────────────────────
|
||||||
@@ -965,6 +1006,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
set({
|
set({
|
||||||
currentRadio: station,
|
currentRadio: station,
|
||||||
currentTrack: null,
|
currentTrack: null,
|
||||||
|
currentPlaybackSource: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
isPlaying: true,
|
isPlaying: true,
|
||||||
@@ -1005,6 +1047,25 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
const newQueue = queue ?? state.queue;
|
const newQueue = queue ?? state.queue;
|
||||||
const idx = newQueue.findIndex(t => t.id === track.id);
|
const idx = newQueue.findIndex(t => t.id === track.id);
|
||||||
|
|
||||||
|
const authState = useAuthStore.getState();
|
||||||
|
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
||||||
|
const preloadedTrackId = get().enginePreloadedTrackId;
|
||||||
|
const keepPreloadHint = preloadedTrackId === track.id;
|
||||||
|
const playbackSourceHint = getPlaybackSourceKind(
|
||||||
|
track.id,
|
||||||
|
authState.activeServerId ?? '',
|
||||||
|
keepPreloadHint ? track.id : null,
|
||||||
|
);
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.info('[psysonic][playTrack-source]', {
|
||||||
|
trackId: track.id,
|
||||||
|
resolvedUrl: url,
|
||||||
|
preloadedTrackId,
|
||||||
|
keepPreloadHint,
|
||||||
|
playbackSourceHint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Set state immediately so the UI updates before the download completes.
|
// Set state immediately so the UI updates before the download completes.
|
||||||
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
|
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
|
||||||
set({
|
set({
|
||||||
@@ -1018,9 +1079,10 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
scrobbled: false,
|
scrobbled: false,
|
||||||
lastfmLoved: false,
|
lastfmLoved: false,
|
||||||
isPlaying: true, // optimistic — reverted on error
|
isPlaying: true, // optimistic — reverted on error
|
||||||
|
currentPlaybackSource: playbackSourceHint,
|
||||||
|
enginePreloadedTrackId: keepPreloadHint ? track.id : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const authState = useAuthStore.getState();
|
|
||||||
if (
|
if (
|
||||||
prevTrack
|
prevTrack
|
||||||
&& prevTrack.id !== track.id
|
&& prevTrack.id !== track.id
|
||||||
@@ -1034,7 +1096,6 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
setDeferHotCachePrefetch(true);
|
setDeferHotCachePrefetch(true);
|
||||||
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
|
||||||
const replayGainDb = authState.replayGainEnabled
|
const replayGainDb = authState.replayGainEnabled
|
||||||
? (authState.replayGainMode === 'album' ? (track.replayGainAlbumDb ?? track.replayGainTrackDb) : track.replayGainTrackDb) ?? null
|
? (authState.replayGainMode === 'album' ? (track.replayGainAlbumDb ?? track.replayGainTrackDb) : track.replayGainTrackDb) ?? null
|
||||||
: null;
|
: null;
|
||||||
@@ -1049,16 +1110,23 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
fallbackDb: authState.replayGainFallbackDb,
|
fallbackDb: authState.replayGainFallbackDb,
|
||||||
manual,
|
manual,
|
||||||
hiResEnabled: authState.enableHiRes,
|
hiResEnabled: authState.enableHiRes,
|
||||||
}).catch((err: unknown) => {
|
})
|
||||||
if (playGeneration !== gen) return;
|
.then(() => {
|
||||||
setDeferHotCachePrefetch(false);
|
|
||||||
console.error('[psysonic] audio_play failed:', err);
|
|
||||||
set({ isPlaying: false });
|
|
||||||
setTimeout(() => {
|
|
||||||
if (playGeneration !== gen) return;
|
if (playGeneration !== gen) return;
|
||||||
get().next(false);
|
if (keepPreloadHint) {
|
||||||
}, 500);
|
usePlayerStore.setState({ enginePreloadedTrackId: null });
|
||||||
});
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (playGeneration !== gen) return;
|
||||||
|
setDeferHotCachePrefetch(false);
|
||||||
|
console.error('[psysonic] audio_play failed:', err);
|
||||||
|
set({ isPlaying: false });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (playGeneration !== gen) return;
|
||||||
|
get().next(false);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
||||||
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
||||||
|
|||||||
@@ -1593,6 +1593,24 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-current-tech-source {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 0;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-current-tech-main {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-divider {
|
.queue-divider {
|
||||||
|
|||||||
@@ -2,6 +2,57 @@ import { buildStreamUrl } from '../api/subsonic';
|
|||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
import { useHotCacheStore } from '../store/hotCacheStore';
|
import { useHotCacheStore } from '../store/hotCacheStore';
|
||||||
|
|
||||||
|
/** Same resolution order as {@link resolvePlaybackUrl} — for UI hints only. */
|
||||||
|
export type PlaybackSourceKind = 'offline' | 'hot' | 'stream';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subsonic `buildStreamUrl()` rotates `t`/`s` on every call; Rust matches by `id` (see `playback_identity`).
|
||||||
|
*/
|
||||||
|
export function streamUrlTrackId(url: string): string | null {
|
||||||
|
if (!url.includes('stream.view')) return null;
|
||||||
|
try {
|
||||||
|
const fromUrl = new URL(url).searchParams.get('id');
|
||||||
|
if (fromUrl) return fromUrl;
|
||||||
|
} catch {
|
||||||
|
// Fallback for non-standard/relative URLs: parse query manually.
|
||||||
|
}
|
||||||
|
const q = url.split('?')[1];
|
||||||
|
if (!q) return null;
|
||||||
|
for (const part of q.split('&')) {
|
||||||
|
const [k, v = ''] = part.split('=');
|
||||||
|
if (k === 'id') {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(v);
|
||||||
|
} catch {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param enginePreloadedTrackId — song id for which `audio_preload` finished into the engine RAM slot
|
||||||
|
* (parsed from `audio:preload-ready` payload URL).
|
||||||
|
*/
|
||||||
|
export function getPlaybackSourceKind(
|
||||||
|
trackId: string,
|
||||||
|
serverId: string,
|
||||||
|
enginePreloadedTrackId: string | null = null,
|
||||||
|
): PlaybackSourceKind {
|
||||||
|
if (useOfflineStore.getState().getLocalUrl(trackId, serverId)) return 'offline';
|
||||||
|
if (useHotCacheStore.getState().getLocalUrl(trackId, serverId)) return 'hot';
|
||||||
|
const resolved = resolvePlaybackUrl(trackId, serverId);
|
||||||
|
if (
|
||||||
|
!resolved.startsWith('psysonic-local://')
|
||||||
|
&& enginePreloadedTrackId
|
||||||
|
&& trackId === enginePreloadedTrackId
|
||||||
|
) {
|
||||||
|
return 'hot';
|
||||||
|
}
|
||||||
|
return 'stream';
|
||||||
|
}
|
||||||
|
|
||||||
/** Offline library → hot playback cache → HTTP stream. */
|
/** Offline library → hot playback cache → HTTP stream. */
|
||||||
export function resolvePlaybackUrl(trackId: string, serverId: string): string {
|
export function resolvePlaybackUrl(trackId: string, serverId: string): string {
|
||||||
const offline = useOfflineStore.getState().getLocalUrl(trackId, serverId);
|
const offline = useOfflineStore.getState().getLocalUrl(trackId, serverId);
|
||||||
|
|||||||
Reference in New Issue
Block a user