mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3130e0ebc7 | |||
| d3d53f1a25 | |||
| acd6f12aba | |||
| 8f93f30e6f | |||
| 6f55b8464c | |||
| 5f15784b7d | |||
| 8bfde08199 | |||
| 49f9f03e5e | |||
| 9034882bf6 | |||
| 588dd8c48d | |||
| 9b24957595 | |||
| f04bfb3d35 | |||
| ff874a62f9 | |||
| a9f650c0fc | |||
| 7e91a5b2a1 | |||
| de1924d5e3 | |||
| 85e91f5375 | |||
| 20cbdd123d | |||
| 098788b56f |
@@ -9,6 +9,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
>
|
||||
|
||||
|
||||
## [1.48.1] - 2026-06-15
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback freeze on track changes
|
||||
|
||||
* Changing tracks — skipping, or the automatic advance at the end of a song — could freeze the interface for several seconds while audio kept playing (the progress bar and lyrics stopped updating). The queue header recomputed its duration totals on every track change instead of only when the queue itself changes; it now recomputes only on queue changes, so track changes stay instant.
|
||||
* This also resolves output-device changes not being applied on Windows: the same freeze was blocking playback from following the newly selected device.
|
||||
|
||||
### Paused or stopped playback restarting on headphone disconnect (macOS)
|
||||
|
||||
* On macOS, pausing or stopping playback and then disconnecting headphones (or otherwise switching the audio output device) could make playback restart on the newly selected device. Playback now reliably stays paused or stopped across a device change.
|
||||
|
||||
### Crash when seeking Opus/Ogg files
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1100](https://github.com/Psychotoxical/psysonic/pull/1100)**
|
||||
|
||||
* Scrubbing the seekbar on an Opus/Ogg file — and then pressing Stop — crashed the whole app (a 1.48 regression from the Symphonia 0.6 migration). The Ogg demuxer recorded its seek bounds only when the source was seekable during the format probe, but probing hid seekability, so the first seek panicked on the audio thread (`Option::unwrap()` on `None`) and took the process down at the audio backend boundary.
|
||||
* Local and in-memory Opus/Ogg sources now stay seekable through the probe, so seeking works correctly. As a safety net, any decoder panic during a seek is contained instead of crashing the app; for Opus/Ogg streamed over HTTP, seeking is a no-op for now rather than a crash.
|
||||
### Discord Rich Presence cover art missing with two server addresses
|
||||
|
||||
* When a server profile had both a local and a public address, Discord Rich Presence showed the placeholder icon instead of the album cover. The cover URL used the local address, which Discord's servers can't reach; it now uses the public address (the same one used for share links).
|
||||
|
||||
### "Minimize to Tray" ignored on the macOS close button
|
||||
|
||||
* On macOS, closing the window with the red close button always quit the app, even with "Minimize to Tray" enabled. The close button now respects the setting — with it on, the window hides to the tray instead of quitting, the same as the tray icon's "Hide".
|
||||
|
||||
|
||||
|
||||
## [1.48.0]
|
||||
|
||||
## Added
|
||||
|
||||
@@ -7,6 +7,12 @@ current line before promoting to `next` / `release`. Technical details and PR cr
|
||||
Within each section, order by **user impact** (most noticeable first) — not PR merge order.
|
||||
`CHANGELOG.md` keeps strict PR order inside Added / Changed / Fixed.
|
||||
|
||||
## [1.48.1]
|
||||
|
||||
## Fixed
|
||||
|
||||
- Fixed an issue with Opus playback where seeking the seekbar could crash the app.
|
||||
|
||||
## [1.48.0]
|
||||
|
||||
## Highlights
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1779560665,
|
||||
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
|
||||
"lastModified": 1781074563,
|
||||
"narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
|
||||
"rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-nwCrZ0enhyLNkS15sgSR9CuQX9cif4fLDNAL2yUPG8s="
|
||||
"npmDepsHash": "sha256-ndXqYgws77qokAXznbQ6BXhXUo3VIaiF1AVs5jCkNCo="
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.48.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.48.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.48.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check:css-imports": "node scripts/check-css-import-graph.mjs",
|
||||
|
||||
Generated
+7
-7
@@ -4114,7 +4114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4167,7 +4167,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-analysis"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
dependencies = [
|
||||
"ebur128",
|
||||
"futures-util",
|
||||
@@ -4186,7 +4186,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-audio"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4216,7 +4216,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-core"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
@@ -4225,7 +4225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-integration"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
dependencies = [
|
||||
"discord-rich-presence",
|
||||
"futures-util",
|
||||
@@ -4242,7 +4242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-library"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
dependencies = [
|
||||
"psysonic-core",
|
||||
"psysonic-integration",
|
||||
@@ -4257,7 +4257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-syncfs"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"id3",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -169,8 +169,14 @@ impl SizedDecoder {
|
||||
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
|
||||
// seekability during probe (same as `new_streaming`) so preview does not
|
||||
// read the entire in-memory file before the first sample.
|
||||
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
|
||||
.then(|| Arc::new(AtomicBool::new(false)));
|
||||
//
|
||||
// Exception: Ogg (Vorbis/Opus/…) must stay seekable through the probe,
|
||||
// otherwise its demuxer never records `phys_byte_range_end` and the first
|
||||
// seek panics (see `container_hint_is_ogg`). This source is fully
|
||||
// in-memory, so the trailing-metadata scan it re-enables is free.
|
||||
let gate_needed = !crate::stream::container_hint_is_mp4(format_hint)
|
||||
&& !crate::stream::container_hint_is_ogg(format_hint);
|
||||
let probe_seek_gate = gate_needed.then(|| Arc::new(AtomicBool::new(false)));
|
||||
let media: Box<dyn MediaSource> = match &probe_seek_gate {
|
||||
Some(gate) => Box::new(ProbeSeekGate {
|
||||
inner: Box::new(source),
|
||||
@@ -315,19 +321,33 @@ impl SizedDecoder {
|
||||
/// Build a decoder from any `MediaSource` (e.g. track-stream or radio).
|
||||
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
|
||||
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
|
||||
/// `source_random_access`: the underlying source can cheaply seek to EOF
|
||||
/// (e.g. a local file), so the probe-time trailing-metadata / stream-end scan
|
||||
/// is not a full download. Progressive sources (ranged HTTP) pass `false`.
|
||||
pub(crate) fn new_streaming(
|
||||
media: Box<dyn MediaSource>,
|
||||
format_hint: Option<&str>,
|
||||
source_tag: &str,
|
||||
source_random_access: bool,
|
||||
) -> Result<Self, String> {
|
||||
// For non-MP4 progressive streams, hide seekability during the probe so
|
||||
// Symphonia 0.6 skips its trailing-metadata scan (which would seek to EOF
|
||||
// and block until the whole file is downloaded). Re-enabled right after.
|
||||
// MP4 keeps seekability (its demuxer needs it to find `moov`; tail is
|
||||
// prefetched separately).
|
||||
//
|
||||
// Ogg also keeps seekability through the probe, but only on random-access
|
||||
// sources: its demuxer records `phys_byte_range_end` during the probe and
|
||||
// panics on the first seek otherwise (see `container_hint_is_ogg`). On a
|
||||
// local file the stream-end scan is cheap; on a progressive ranged stream
|
||||
// it would force a full download, so there we keep the gate and accept
|
||||
// that seeking is a no-op (the panic itself is contained in `try_seek`).
|
||||
let stream_len = media.byte_len();
|
||||
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
|
||||
.then(|| Arc::new(AtomicBool::new(false)));
|
||||
let ogg_needs_seekable_probe =
|
||||
source_random_access && crate::stream::container_hint_is_ogg(format_hint);
|
||||
let gate_needed = !crate::stream::container_hint_is_mp4(format_hint)
|
||||
&& !ogg_needs_seekable_probe;
|
||||
let probe_seek_gate = gate_needed.then(|| Arc::new(AtomicBool::new(false)));
|
||||
let media: Box<dyn MediaSource> = match &probe_seek_gate {
|
||||
Some(gate) => Box::new(ProbeSeekGate { inner: media, seekable: gate.clone() }),
|
||||
None => media,
|
||||
@@ -588,20 +608,36 @@ impl Source for SizedDecoder {
|
||||
|
||||
let to_skip = self.current_frame_offset % self.channels().get() as usize;
|
||||
|
||||
let seek_res = self
|
||||
.format
|
||||
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
|
||||
.map_err(|e| rodio::source::SeekError::Other(
|
||||
std::sync::Arc::new(std::io::Error::other(e.to_string()))
|
||||
))?;
|
||||
// symphonia 0.6's OGG demuxer can `panic!` (e.g. `Option::unwrap()` on
|
||||
// `None` in `OggReader::do_seek`) on some streams instead of returning
|
||||
// an `Err`. `try_seek` runs on rodio's cpal output thread, so an escaping
|
||||
// panic poisons the engine mutexes and then aborts the whole process at
|
||||
// the non-unwinding cpal FFI boundary (the "crash on Stop" is a downstream
|
||||
// symptom of that poison). Contain the unwind here — including the packet
|
||||
// reads in `refine_position`, which can hit the same broken demuxer state —
|
||||
// and surface it as a recoverable `SeekError` so the engine stays alive
|
||||
// (the seek becomes a no-op rather than killing playback).
|
||||
let seek_outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let seek_res = self
|
||||
.format
|
||||
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.refine_position(seek_res)?;
|
||||
Ok::<(), String>(())
|
||||
}));
|
||||
|
||||
self.refine_position(seek_res)
|
||||
.map_err(|e| rodio::source::SeekError::Other(
|
||||
std::sync::Arc::new(std::io::Error::other(e))
|
||||
))?;
|
||||
|
||||
self.current_frame_offset += to_skip;
|
||||
Ok(())
|
||||
match seek_outcome {
|
||||
Ok(Ok(())) => {
|
||||
self.current_frame_offset += to_skip;
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => Err(rodio::source::SeekError::Other(std::sync::Arc::new(
|
||||
std::io::Error::other(e),
|
||||
))),
|
||||
Err(_panic) => Err(rodio::source::SeekError::Other(std::sync::Arc::new(
|
||||
std::io::Error::other("seek panicked inside the demuxer (contained)"),
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1019,8 +1055,9 @@ mod tests {
|
||||
#[test]
|
||||
fn new_streaming_constructs_from_synthetic_wav() {
|
||||
let wav = synthetic_wav_bytes(0.5);
|
||||
let decoder = SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream")
|
||||
.expect("streaming WAV decode setup");
|
||||
let decoder =
|
||||
SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream", true)
|
||||
.expect("streaming WAV decode setup");
|
||||
assert_eq!(decoder.spec.rate(), 44_100);
|
||||
assert_eq!(decoder.spec.channels().count(), 1);
|
||||
// Live streams report no total duration.
|
||||
@@ -1033,6 +1070,7 @@ mod tests {
|
||||
seekable_source(vec![0x00u8; 64]),
|
||||
None,
|
||||
"test-stream",
|
||||
true,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
reader: Box::new(LocalFileSource { file, len }),
|
||||
format_hint: url_format_hint(url),
|
||||
tag: "LocalFile[device-resume]",
|
||||
random_access: true,
|
||||
mp4_probe_gate: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,15 @@ pub(crate) async fn reopen_output_stream(
|
||||
if !opened {
|
||||
return false;
|
||||
}
|
||||
// When we're not actively playing (paused/stopped), bump the generation
|
||||
// before stopping the old sink so the still-running progress task sees the
|
||||
// mismatch and bails out instead of emitting a spurious `audio:ended` —
|
||||
// which would otherwise trigger a frontend restart of paused playback
|
||||
// (#1094). The active-playback path bumps inside
|
||||
// `try_resume_after_device_change`, so only guard the non-playing case here.
|
||||
if !snapshot.is_playing {
|
||||
engine.generation.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
if let Some(s) = current.lock().unwrap().sink.take() {
|
||||
s.stop();
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ pub(crate) enum PlayInput {
|
||||
reader: Box<dyn MediaSource>,
|
||||
format_hint: Option<String>,
|
||||
tag: &'static str,
|
||||
/// Source can cheaply seek to EOF (local file). Drives whether Ogg keeps
|
||||
/// seekability through the probe so its seek path does not panic.
|
||||
random_access: bool,
|
||||
/// When set, Symphonia probe waits for moov (tail or fast-start prefix).
|
||||
mp4_probe_gate: Option<super::stream::RangedMp4ProbeGate>,
|
||||
},
|
||||
@@ -201,6 +204,7 @@ fn open_local_file_input(
|
||||
reader: Box::new(reader),
|
||||
format_hint: local_hint,
|
||||
tag: "local-file",
|
||||
random_access: true,
|
||||
mp4_probe_gate: None,
|
||||
})
|
||||
}
|
||||
@@ -345,6 +349,7 @@ async fn open_ranged_or_streaming_input(
|
||||
reader: Box::new(reader),
|
||||
format_hint: stream_hint,
|
||||
tag: "ranged-stream",
|
||||
random_access: false,
|
||||
mp4_probe_gate,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ async fn open_preview_decoder(
|
||||
};
|
||||
let hint = stream_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream", false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
|
||||
@@ -124,7 +124,7 @@ pub async fn audio_play_radio(
|
||||
|
||||
let hint_clone = fmt_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio")
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio", false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
@@ -345,6 +345,7 @@ async fn build_source_from_play_input(
|
||||
reader,
|
||||
format_hint: media_hint,
|
||||
tag,
|
||||
random_access,
|
||||
mp4_probe_gate,
|
||||
} => {
|
||||
if let Some(gate) = mp4_probe_gate.as_ref() {
|
||||
@@ -354,7 +355,7 @@ async fn build_source_from_play_input(
|
||||
}
|
||||
}
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag)
|
||||
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag, random_access)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
@@ -375,7 +376,12 @@ async fn build_source_from_play_input(
|
||||
PlayInput::Streaming { reader, format_hint: stream_hint } => {
|
||||
is_seekable = false;
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), stream_hint.as_deref(), "track-stream")
|
||||
SizedDecoder::new_streaming(
|
||||
Box::new(reader),
|
||||
stream_hint.as_deref(),
|
||||
"track-stream",
|
||||
false,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
@@ -21,6 +21,23 @@ pub(crate) use mp4::{
|
||||
container_hint_is_mp4, isobmff_buffer_looks_complete, log_isobmff_buffer_diagnostic,
|
||||
mp4_needs_tail_prefetch, mp4_suspect_zero_holes,
|
||||
};
|
||||
|
||||
/// True when the container hint denotes an Ogg-encapsulated stream (Vorbis,
|
||||
/// Opus, Speex, FLAC-in-Ogg).
|
||||
///
|
||||
/// symphonia 0.6's Ogg demuxer records the physical stream's byte range at
|
||||
/// construction time, but only when the source reports `is_seekable()` *during
|
||||
/// the probe*. If seekability is hidden then (see `ProbeSeekGate`),
|
||||
/// `phys_byte_range_end` stays `None` and the first real seek panics with
|
||||
/// `Option::unwrap()` on `None` (`demuxer.rs:180`). Sources that can cheaply
|
||||
/// seek to EOF must therefore stay seekable through the probe for Ogg.
|
||||
pub(crate) fn container_hint_is_ogg(hint: Option<&str>) -> bool {
|
||||
let Some(h) = hint else { return false };
|
||||
matches!(
|
||||
h.to_ascii_lowercase().as_str(),
|
||||
"ogg" | "oga" | "ogx" | "opus" | "spx"
|
||||
)
|
||||
}
|
||||
pub(crate) use local_file::LocalFileSource;
|
||||
pub(crate) use radio::{RadioLiveState, RadioSharedFlags, radio_download_task};
|
||||
pub(crate) use ranged_http::{RangedHttpSource, ranged_download_task};
|
||||
|
||||
+20
-20
@@ -502,11 +502,20 @@ pub fn run() {
|
||||
let app_handle = app.handle().clone();
|
||||
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
|
||||
match event {
|
||||
MediaControlEvent::Toggle
|
||||
| MediaControlEvent::Play
|
||||
| MediaControlEvent::Pause => {
|
||||
// Keep Play/Pause distinct from Toggle: the OS
|
||||
// (notably macOS on audio-route changes, e.g. a
|
||||
// headphone disconnect) sends an explicit Pause,
|
||||
// and collapsing all three into a toggle would
|
||||
// resume paused playback on the new device (#1094).
|
||||
MediaControlEvent::Toggle => {
|
||||
let _ = app_handle.emit("media:play-pause", ());
|
||||
}
|
||||
MediaControlEvent::Play => {
|
||||
let _ = app_handle.emit("media:play", ());
|
||||
}
|
||||
MediaControlEvent::Pause => {
|
||||
let _ = app_handle.emit("media:pause", ());
|
||||
}
|
||||
MediaControlEvent::Next => {
|
||||
let _ = app_handle.emit("media:next", ());
|
||||
}
|
||||
@@ -599,24 +608,15 @@ pub fn run() {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
if window.label() == "main" {
|
||||
api.prevent_close();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// On macOS the red close button quits the app entirely.
|
||||
// Route through JS so playback position + Orbit state get
|
||||
// flushed; exit_app on the way back stops the audio engine.
|
||||
let _ = window.emit("app:force-quit", ());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
// Pause rendering before JS decides whether to hide to tray or exit.
|
||||
if let Some(w) = window.app_handle().get_webview_window("main") {
|
||||
let _ = w.eval(PAUSE_RENDERING_JS);
|
||||
}
|
||||
// Let JS decide: minimize to tray or exit, based on user setting.
|
||||
let _ = window.emit("window:close-requested", ());
|
||||
// All platforms: pause rendering, then let JS decide hide-to-tray
|
||||
// vs exit based on the minimizeToTray setting. macOS previously
|
||||
// always force-quit on the red close button, ignoring the setting
|
||||
// (#1103). The tray "Exit" item still emits app:force-quit for an
|
||||
// unconditional quit.
|
||||
if let Some(w) = window.app_handle().get_webview_window("main") {
|
||||
let _ = w.eval(PAUSE_RENDERING_JS);
|
||||
}
|
||||
let _ = window.emit("window:close-requested", ());
|
||||
} else if window.label() == "mini" {
|
||||
// Native close on the mini: hide instead of destroying so
|
||||
// state is preserved, and restore the main window.
|
||||
|
||||
@@ -84,14 +84,21 @@ pub(crate) fn mpris_set_metadata(
|
||||
let duration = duration_secs.map(Duration::from_secs_f64);
|
||||
let mut guard = controls.lock().unwrap();
|
||||
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
|
||||
ctrl.set_metadata(MediaMetadata {
|
||||
// DIAG #1102: log the cover URL going to the OS media controls + whether
|
||||
// set_metadata succeeds (a throwing cover_url would also drop title/artist).
|
||||
crate::app_eprintln!("[mpris-cover] cover_url={:?}", cover_url);
|
||||
let res = ctrl.set_metadata(MediaMetadata {
|
||||
title: title.as_deref(),
|
||||
artist: artist.as_deref(),
|
||||
album: album.as_deref(),
|
||||
cover_url: cover_url.as_deref(),
|
||||
duration,
|
||||
})
|
||||
.map_err(|e| format!("MPRIS set_metadata failed: {e:?}"))
|
||||
});
|
||||
match &res {
|
||||
Ok(()) => crate::app_eprintln!("[mpris-cover] set_metadata OK"),
|
||||
Err(e) => crate::app_eprintln!("[mpris-cover] set_metadata ERR: {e:?}"),
|
||||
}
|
||||
res.map_err(|e| format!("MPRIS set_metadata failed: {e:?}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.48.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -44,23 +44,31 @@ export function QueueHeader({
|
||||
// H1 mitigation: a mass-resolve burst (queue restore, prefetch window slide)
|
||||
// bumps `version` dozens of times in one frame; useDeferredValue coalesces
|
||||
// the burst into a single low-priority commit so long queues do not block
|
||||
// the main thread on every cache tick. The aggregation itself is a single
|
||||
// pass — one loop produces both totals so a 50k-track queue costs one walk,
|
||||
// not two.
|
||||
// the main thread on every cache tick.
|
||||
//
|
||||
// The O(n) walk is keyed on `queue`/`deferredVersion` only — NOT `queueIndex`.
|
||||
// A skip moves only `queueIndex`, so it must not re-walk the whole queue: that
|
||||
// synchronous O(n) pass on every track change froze the UI for seconds on very
|
||||
// large queues (#1072, and the device-switch fallback in #1090). Instead we
|
||||
// build a cumulative-duration prefix (`cumSecs[i]` = summed duration of tracks
|
||||
// [0, i)) once per queue/cache change, then derive the future total as an O(1)
|
||||
// lookup below. A 50k-track queue costs one walk per queue/cache change, zero
|
||||
// per track change.
|
||||
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||||
const deferredVersion = useDeferredValue(version);
|
||||
const { totalSecs, futureTracksDuration } = useMemo(() => {
|
||||
if (queue.length === 0) return { totalSecs: 0, futureTracksDuration: 0 };
|
||||
let total = 0;
|
||||
let future = 0;
|
||||
const { totalSecs, cumSecs } = useMemo(() => {
|
||||
const cum = new Float64Array(queue.length + 1);
|
||||
for (let i = 0; i < queue.length; i += 1) {
|
||||
const dur = resolveQueueTrack(queue[i]).duration || 0;
|
||||
total += dur;
|
||||
if (i > queueIndex) future += dur;
|
||||
cum[i + 1] = cum[i] + (resolveQueueTrack(queue[i]).duration || 0);
|
||||
}
|
||||
return { totalSecs: total, futureTracksDuration: future };
|
||||
return { totalSecs: cum[queue.length], cumSecs: cum };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queue, queueIndex, deferredVersion]);
|
||||
}, [queue, deferredVersion]);
|
||||
// Tracks strictly after the current index — O(1) per skip.
|
||||
const futureTracksDuration = Math.max(
|
||||
0,
|
||||
totalSecs - cumSecs[Math.min(queueIndex + 1, queue.length)],
|
||||
);
|
||||
|
||||
const currentDuration = queue[queueIndex] ? resolveQueueTrack(queue[queueIndex]).duration : 0;
|
||||
const remainingSecs = Math.max(0, (currentDuration ?? 0) - currentTime + futureTracksDuration);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* coverArtUrlForDiscord — Discord fetches the large image from its own servers,
|
||||
* so the URL must use the public address, not the LAN-preferred connect URL
|
||||
* (regression from the dual-address feature).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeServer } from '@/test/helpers/factories';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { coverArtUrlForDiscord } from './discord';
|
||||
import type { CoverArtRef } from '../types';
|
||||
|
||||
function refForServer(serverId: string, url: string): CoverArtRef {
|
||||
return {
|
||||
cacheKind: 'album',
|
||||
cacheEntityId: 'al-1',
|
||||
fetchCoverArtId: 'al-1',
|
||||
serverScope: { kind: 'server', serverId, url, username: 'tester', password: 'pw' },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('coverArtUrlForDiscord', () => {
|
||||
it('uses the public address on a dual-address profile, not the LAN one', async () => {
|
||||
const server = makeServer({
|
||||
url: 'http://192.168.1.50:4533',
|
||||
alternateUrl: 'https://music.example.com',
|
||||
});
|
||||
useAuthStore.setState({ servers: [server], activeServerId: server.id } as never);
|
||||
|
||||
const url = await coverArtUrlForDiscord(refForServer(server.id, server.url));
|
||||
|
||||
expect(url).toContain('music.example.com');
|
||||
expect(url).not.toContain('192.168.1.50');
|
||||
});
|
||||
|
||||
it('returns the single configured address when there is no alternate', async () => {
|
||||
const server = makeServer({ url: 'https://music.example.com', alternateUrl: undefined });
|
||||
useAuthStore.setState({ servers: [server], activeServerId: server.id } as never);
|
||||
|
||||
const url = await coverArtUrlForDiscord(refForServer(server.id, server.url));
|
||||
|
||||
expect(url).toContain('music.example.com');
|
||||
});
|
||||
|
||||
it('resolves a playback scope to the active profile (public address)', async () => {
|
||||
// playback scope is the common case for locally-cached tracks; it must
|
||||
// resolve to the active server, not yield a null cover.
|
||||
const server = makeServer({
|
||||
url: 'http://192.168.1.50:4533',
|
||||
alternateUrl: 'https://music.example.com',
|
||||
});
|
||||
useAuthStore.setState({ servers: [server], activeServerId: server.id } as never);
|
||||
|
||||
const ref: CoverArtRef = {
|
||||
cacheKind: 'album',
|
||||
cacheEntityId: 'al-1',
|
||||
fetchCoverArtId: 'al-1',
|
||||
serverScope: { kind: 'playback' },
|
||||
};
|
||||
const url = await coverArtUrlForDiscord(ref);
|
||||
|
||||
expect(url).toContain('music.example.com');
|
||||
expect(url).not.toContain('192.168.1.50');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,52 @@
|
||||
import { buildCoverArtFetchUrl } from '../fetchUrl';
|
||||
import { buildCoverArtUrlForServer } from '../../api/subsonicStreamUrl';
|
||||
import { serverShareBaseUrl } from '../../utils/server/serverEndpoint';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { CoverArtRef } from '../types';
|
||||
|
||||
/**
|
||||
* Discord large image — always the HTTPS fetch URL, never a local cache path.
|
||||
* Discord Rich Presence images are fetched by Discord's own servers, so the
|
||||
* large_image must be a key or an https:// URL they can reach. A `file://` path
|
||||
* to the on-disk webp cache (what MPRIS uses) is meaningless to Discord and
|
||||
* silently falls back to the app icon — so we hand it the getCoverArt URL.
|
||||
* Discord large image — an https:// URL Discord's own servers can reach.
|
||||
*
|
||||
* Unlike every other cover fetch we must NOT use the connect URL: that prefers
|
||||
* the LAN address (fast for the app itself), but Discord fetches the image
|
||||
* remotely, so a `http://192.168.x.x` address is unreachable and falls back to
|
||||
* the app icon. Discord is an external consumer just like a share link, so use
|
||||
* `serverShareBaseUrl` (public address preferred when both are set).
|
||||
*
|
||||
* Resolve the profile straight from the store: a `playback`/`active` scope
|
||||
* always means the active server (a cross-server track gets an explicit
|
||||
* `server` scope), so we never route through `getPlaybackServerId()`, whose
|
||||
* empty-string / index-key returns previously yielded a null cover URL on
|
||||
* locally-cached tracks.
|
||||
*/
|
||||
export async function coverArtUrlForDiscord(ref: CoverArtRef): Promise<string | null> {
|
||||
return buildCoverArtFetchUrl(ref, 800) || null;
|
||||
const { serverScope, fetchCoverArtId } = ref;
|
||||
const auth = useAuthStore.getState();
|
||||
|
||||
const profile =
|
||||
serverScope.kind === 'server'
|
||||
? auth.servers.find(s => s.id === serverScope.serverId)
|
||||
: auth.servers.find(s => s.id === auth.activeServerId);
|
||||
|
||||
if (profile) {
|
||||
return buildCoverArtUrlForServer(
|
||||
serverShareBaseUrl(profile),
|
||||
profile.username,
|
||||
profile.password,
|
||||
fetchCoverArtId,
|
||||
800,
|
||||
) || null;
|
||||
}
|
||||
|
||||
// Server scope carries its own URL/creds even when not a saved profile.
|
||||
if (serverScope.kind === 'server') {
|
||||
return buildCoverArtUrlForServer(
|
||||
serverShareBaseUrl({ url: serverScope.url }),
|
||||
serverScope.username,
|
||||
serverScope.password,
|
||||
fetchCoverArtId,
|
||||
800,
|
||||
) || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { makeTrack } from '@/test/helpers/factories';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { getSeekFallbackVisualTarget, setSeekFallbackVisualTarget } from '@/store/seekFallbackState';
|
||||
import { setIsAudioPaused } from '@/store/engineState';
|
||||
import { useAudioDeviceBridge } from './useAudioDeviceBridge';
|
||||
|
||||
const track = makeTrack({ id: 't1', duration: 300 });
|
||||
@@ -29,6 +30,8 @@ function mountBridge() {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
setSeekFallbackVisualTarget(null);
|
||||
// Module-level engine flag isn't covered by resetAllStores — reset explicitly.
|
||||
setIsAudioPaused(false);
|
||||
// Default: a track is playing.
|
||||
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
|
||||
});
|
||||
@@ -92,6 +95,19 @@ describe('audio:device-changed', () => {
|
||||
|
||||
expect(playTrack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not restart when the engine is paused even if isPlaying is stale-true (#1094)', () => {
|
||||
const playTrack = vi.fn();
|
||||
const resetAudioPause = vi.fn();
|
||||
usePlayerStore.setState({ playTrack, resetAudioPause, isPlaying: true } as never);
|
||||
setIsAudioPaused(true);
|
||||
mountBridge();
|
||||
|
||||
emitTauriEvent('audio:device-changed', 45.0);
|
||||
|
||||
expect(playTrack).not.toHaveBeenCalled();
|
||||
expect(resetAudioPause).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── audio:device-reset ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -3,6 +3,7 @@ import { listen } from '@tauri-apps/api/event';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { setSeekFallbackVisualTarget } from '../../store/seekFallbackState';
|
||||
import { getIsAudioPaused } from '../../store/engineState';
|
||||
|
||||
/** Audio output device lifecycle: device switches (Bluetooth headphones, USB
|
||||
* DAC, …) and pinned-device-unplugged fallbacks emitted by the Rust
|
||||
@@ -23,7 +24,10 @@ export function useAudioDeviceBridge() {
|
||||
const resumeAt = typeof event.payload === 'number' ? event.payload : 0;
|
||||
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
|
||||
if (!currentTrack) return;
|
||||
if (isPlaying) {
|
||||
// Only restart playback when transport is *provably* active. `isPlaying`
|
||||
// alone can be stale/desynced on a device change (#1094); the engine-paused
|
||||
// flag is the source of truth — if paused, just reset for the cold path.
|
||||
if (isPlaying && !getIsAudioPaused()) {
|
||||
if (resumeAt > 0.5 && currentTrack.duration > 0) {
|
||||
setSeekFallbackVisualTarget({
|
||||
trackId: currentTrack.id,
|
||||
@@ -55,7 +59,10 @@ export function useAudioDeviceBridge() {
|
||||
const resumeAt = typeof event.payload === 'number' ? event.payload : 0;
|
||||
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
|
||||
if (!currentTrack) return;
|
||||
if (isPlaying) {
|
||||
// Only restart playback when transport is *provably* active. `isPlaying`
|
||||
// alone can be stale/desynced on a device change (#1094); the engine-paused
|
||||
// flag is the source of truth — if paused, just reset for the cold path.
|
||||
if (isPlaying && !getIsAudioPaused()) {
|
||||
if (resumeAt > 0.5 && currentTrack.duration > 0) {
|
||||
setSeekFallbackVisualTarget({
|
||||
trackId: currentTrack.id,
|
||||
|
||||
Reference in New Issue
Block a user