mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71a8ad673d | |||
| a1d8116b96 | |||
| 48a39d5930 | |||
| 73c766cb8d | |||
| 9711047fc6 | |||
| 9ead421451 | |||
| da1ad78fbe | |||
| de1924d5e3 | |||
| 85e91f5375 | |||
| 20cbdd123d | |||
| 098788b56f |
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"
|
||||
|
||||
@@ -61,17 +61,20 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
}
|
||||
let url = match snap.url.as_deref() {
|
||||
Some(u) if !u.is_empty() => u,
|
||||
_ => return false,
|
||||
_ => { crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: url empty/none"); return false; }
|
||||
};
|
||||
|
||||
let Some(engine) = app.try_state::<AudioEngine>() else {
|
||||
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: no AudioEngine state");
|
||||
return false;
|
||||
};
|
||||
|
||||
// Skip radio — live streams don't have a resume position.
|
||||
if engine.radio_state.lock().unwrap().is_some() {
|
||||
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: radio active");
|
||||
return false;
|
||||
}
|
||||
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume: url={url} (kind={})", if url.starts_with("psysonic-local://") { "local" } else { "http" });
|
||||
|
||||
// Build a PlayInput without re-downloading:
|
||||
// - psysonic-local:// → seekable file
|
||||
@@ -118,7 +121,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
return false;
|
||||
}
|
||||
},
|
||||
None => return false, // not fully cached yet — frontend will re-fetch
|
||||
None => { crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: HTTP not fully cached (no RAM cache, no spill) for url={url}"); return false; } // not fully cached yet — frontend will re-fetch
|
||||
}
|
||||
};
|
||||
PlayInput::Bytes(bytes)
|
||||
@@ -131,6 +134,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
*engine.current_playback_url.lock().unwrap() = Some(url.to_owned());
|
||||
|
||||
if engine.generation.load(Ordering::SeqCst) != gen {
|
||||
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: generation race (early, after url/cache build)");
|
||||
return false; // raced with another audio_play
|
||||
}
|
||||
|
||||
@@ -169,12 +173,13 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
{
|
||||
Ok(ps) => ps,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[device-resume] source build failed: {e}");
|
||||
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: source build failed: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if engine.generation.load(Ordering::SeqCst) != gen {
|
||||
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: generation race (late, after source build)");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,15 @@ pub(crate) async fn reopen_output_stream(
|
||||
// Falls back gracefully to the frontend path if conditions aren't met.
|
||||
let resumed = try_resume_after_device_change(app, &snapshot).await;
|
||||
|
||||
// [DIAG #1090 — TEMPORARY] What does Rust hand to the frontend? resumed=true
|
||||
// → null payload (frontend does nothing). resumed=false → f64 → frontend
|
||||
// calls playTrack. This tells us which frontend branch the hang follows.
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] DIAG-1090 reopen returning: resumed={resumed} was_playing={} payload={}",
|
||||
snapshot.is_playing,
|
||||
if resumed { "null".to_string() } else { format!("{:?}", snapshot.current_time_secs) }
|
||||
);
|
||||
|
||||
match notify {
|
||||
ReopenNotify::DeviceChanged => {
|
||||
// null → Rust already resumed; frontend skips playTrack
|
||||
@@ -267,6 +276,13 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
(default, available)
|
||||
}).await.unwrap_or((None, vec![]));
|
||||
|
||||
// [DIAG #1090 — TEMPORARY] Every poll: what does cpal report as the
|
||||
// current default, and is anything pinned? If `current_default` never
|
||||
// changes when the OS default switches, cpal isn't detecting it (root A).
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] DIAG-1090 poll: pinned={pinned:?} current_default={current_default:?} last_default={last_default:?}"
|
||||
);
|
||||
|
||||
// Empty list (only when we actually enumerated for a pinned device)
|
||||
// almost always means a transient enumeration failure, not that every
|
||||
// output device vanished. Treating it as "pinned missing" caused false
|
||||
@@ -317,6 +333,13 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// [DIAG #1090 — TEMPORARY] Case 1 fired: cpal saw the default change.
|
||||
// If you see THIS line but audio stays on the old device, the reopen
|
||||
// path opens cpal's stale default (root B).
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] DIAG-1090 Case-1 fired: {last_default:?} -> {current_default:?} — reopening on new default"
|
||||
);
|
||||
|
||||
last_default = current_default.clone();
|
||||
|
||||
let Some(_new_name) = current_default else { continue };
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { listen } from '@tauri-apps/api/event';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { setSeekFallbackVisualTarget } from '../../store/seekFallbackState';
|
||||
// [DIAG #1090/#1072 — TEMPORARY] freeze-survivable markers (see diag1090.ts).
|
||||
import { markDiag1090 as diag1090, recoverDiag1090 } from '../../store/diag1090';
|
||||
|
||||
/** Audio output device lifecycle: device switches (Bluetooth headphones, USB
|
||||
* DAC, …) and pinned-device-unplugged fallbacks emitted by the Rust
|
||||
@@ -14,14 +16,20 @@ import { setSeekFallbackVisualTarget } from '../../store/seekFallbackState';
|
||||
* must call playTrack and seek to that position.
|
||||
*/
|
||||
export function useAudioDeviceBridge() {
|
||||
// [DIAG #1090/#1072 — TEMPORARY] On mount, surface any marker trail the
|
||||
// previous (frozen, then killed) session left behind.
|
||||
useEffect(() => { recoverDiag1090(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen('audio:device-changed', (event) => {
|
||||
diag1090(`device-changed received payload=${JSON.stringify(event.payload)}`);
|
||||
// null payload = Rust handled internal replay; nothing to do here.
|
||||
if (event.payload === null) return;
|
||||
|
||||
const resumeAt = typeof event.payload === 'number' ? event.payload : 0;
|
||||
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
|
||||
diag1090(`state currentTrack=${!!currentTrack} isPlaying=${isPlaying} resumeAt=${resumeAt}`);
|
||||
if (!currentTrack) return;
|
||||
if (isPlaying) {
|
||||
if (resumeAt > 0.5 && currentTrack.duration > 0) {
|
||||
@@ -31,7 +39,9 @@ export function useAudioDeviceBridge() {
|
||||
setAtMs: Date.now(),
|
||||
});
|
||||
}
|
||||
diag1090('before playTrack');
|
||||
playTrack(currentTrack);
|
||||
diag1090('after playTrack (returned)');
|
||||
} else {
|
||||
// Paused: clear warm-pause flag so the next resume uses the cold path
|
||||
// (audio_play + seek) which creates a new Sink on the new device.
|
||||
@@ -47,6 +57,7 @@ export function useAudioDeviceBridge() {
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen('audio:device-reset', (event) => {
|
||||
diag1090(`device-reset received payload=${JSON.stringify(event.payload)}`);
|
||||
useAuthStore.getState().setAudioOutputDevice(null);
|
||||
|
||||
// null payload = Rust handled internal replay on the new default device.
|
||||
@@ -54,6 +65,7 @@ export function useAudioDeviceBridge() {
|
||||
|
||||
const resumeAt = typeof event.payload === 'number' ? event.payload : 0;
|
||||
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
|
||||
diag1090(`device-reset state currentTrack=${!!currentTrack} isPlaying=${isPlaying} resumeAt=${resumeAt}`);
|
||||
if (!currentTrack) return;
|
||||
if (isPlaying) {
|
||||
if (resumeAt > 0.5 && currentTrack.duration > 0) {
|
||||
@@ -63,8 +75,11 @@ export function useAudioDeviceBridge() {
|
||||
setAtMs: Date.now(),
|
||||
});
|
||||
}
|
||||
diag1090('device-reset: before playTrack (RESTARTS PLAYBACK)');
|
||||
playTrack(currentTrack);
|
||||
diag1090('device-reset: after playTrack (returned)');
|
||||
} else {
|
||||
diag1090('device-reset: paused → resetAudioPause (no restart)');
|
||||
resetAudioPause();
|
||||
}
|
||||
}).then(u => { unlisten = u; });
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// [DIAG #1090/#1072 — TEMPORARY] Freeze-survivable marker trail.
|
||||
//
|
||||
// The UI hang blocks the JS main thread, so async `invoke()` logging loses the
|
||||
// final marker before the freeze (the IPC flush never runs). We therefore also
|
||||
// write each marker SYNCHRONOUSLY to localStorage, which persists across the
|
||||
// freeze and the process kill. On the next launch `recoverDiag1090()` reads the
|
||||
// trail back and forwards it to the Rust log, revealing the last reached point
|
||||
// right before the hang. Remove this file with the real fix.
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from './authStore';
|
||||
|
||||
const KEY = 'diag1090_trail';
|
||||
|
||||
export function markDiag1090(m: string): void {
|
||||
// Synchronous, freeze-proof: even if the thread blocks on the next line, this
|
||||
// value is already committed to disk.
|
||||
try {
|
||||
const prev = localStorage.getItem(KEY);
|
||||
const next = (prev ? prev + '\n' : '') + `${Date.now()}: ${m}`;
|
||||
localStorage.setItem(KEY, next.split('\n').slice(-30).join('\n'));
|
||||
} catch {
|
||||
// ignore — diagnostics must never throw
|
||||
}
|
||||
// Best-effort live log (only survives when no freeze follows).
|
||||
if (useAuthStore.getState().loggingMode !== 'debug') return;
|
||||
void invoke('frontend_debug_log', { scope: 'diag1090', message: m }).catch(() => {});
|
||||
}
|
||||
|
||||
// Call once on app start: if the previous session left a trail (it froze), emit
|
||||
// it to the Rust log, then clear it.
|
||||
export function recoverDiag1090(): void {
|
||||
try {
|
||||
// Build marker: confirms in the log that THIS diagnostic code is running,
|
||||
// so we never again wonder whether the right commit was built.
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'diag',
|
||||
message: 'DIAG-BUILD active: persist-timing v2 + always-log-player',
|
||||
}).catch(() => {});
|
||||
const trail = localStorage.getItem(KEY);
|
||||
if (!trail) return;
|
||||
localStorage.removeItem(KEY);
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'diag1090',
|
||||
message: `RECOVERED TRAIL from previous session (last line = point before freeze):\n${trail}`,
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from './loudnessGainCache';
|
||||
import { refreshLoudnessForTrack } from './loudnessRefresh';
|
||||
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
|
||||
import { markDiag1090 as diag1090 } from './diag1090';
|
||||
import { useOrbitStore } from './orbitStore';
|
||||
import {
|
||||
playbackSourceHintForResolvedUrl,
|
||||
@@ -102,6 +103,7 @@ export function runPlayTrack(
|
||||
_orbitConfirmed: boolean,
|
||||
targetQueueIndex: number | undefined,
|
||||
): void {
|
||||
diag1090(`runPlayTrack: ENTRY trackId=${track.id} hasQueue=${!!queue}`);
|
||||
// Orbit bulk-gate: only gate when the `queue` argument *replaces*
|
||||
// the current queue (Play All / Play Album / Play Playlist / Hero
|
||||
// play buttons). Navigation calls — queue-row click, next(),
|
||||
@@ -318,6 +320,7 @@ export function runPlayTrack(
|
||||
} else if (queueSid) {
|
||||
seedQueueResolver(queueSid, [scopedTrack]);
|
||||
}
|
||||
diag1090('runPlayTrack: before set(currentTrack)');
|
||||
set({
|
||||
currentTrack: scopedTrack,
|
||||
currentRadio: null,
|
||||
@@ -338,6 +341,7 @@ export function runPlayTrack(
|
||||
currentPlaybackSource: playbackSourceHint,
|
||||
enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null,
|
||||
});
|
||||
diag1090('runPlayTrack: after set ok');
|
||||
|
||||
if (
|
||||
prevTrack
|
||||
@@ -361,6 +365,7 @@ export function runPlayTrack(
|
||||
isReplayGainActive(), authStateNow.replayGainMode,
|
||||
);
|
||||
const replayGainPeak = isReplayGainActive() ? (scopedTrack.replayGainPeak ?? null) : null;
|
||||
diag1090('runPlayTrack: before invoke audio_play');
|
||||
invoke('audio_play', {
|
||||
url,
|
||||
volume: state.volume,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createJSONStorage, type PersistStorage, type StateStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core'; // [DIAG #1072 — TEMPORARY]
|
||||
|
||||
/**
|
||||
* `localStorage` wrapped so a failed write never throws.
|
||||
@@ -29,6 +30,9 @@ const safeLocalStorage: StateStorage = {
|
||||
}
|
||||
},
|
||||
setItem: (name, value) => {
|
||||
// [DIAG #1072 — TEMPORARY] Time the synchronous write. If this is the
|
||||
// skip-freeze, slow writes (large queue under disk load) show up here.
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
localStorage.setItem(name, value);
|
||||
quotaWarned.delete(name);
|
||||
@@ -41,6 +45,15 @@ const safeLocalStorage: StateStorage = {
|
||||
);
|
||||
}
|
||||
}
|
||||
const dt = performance.now() - t0;
|
||||
// Always log the player key (the skip path); other keys only when slow. If
|
||||
// these stay small while set() stalls for seconds, the freeze is the render.
|
||||
if (name === 'psysonic-player' || dt > 80) {
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'diag1072',
|
||||
message: `persist write key=${name} ${Math.round(dt)}ms size=${(value.length / 1024).toFixed(1)}KB`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
},
|
||||
removeItem: (name) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user