merge: integrate origin/main into fix/statistics-i18n-small-fixes

This commit is contained in:
Maxim Isaev
2026-04-10 01:42:14 +03:00
7 changed files with 112 additions and 6 deletions
+72
View File
@@ -2883,3 +2883,75 @@ pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngin
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) { pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
state.gapless_enabled.store(enabled, Ordering::Relaxed); state.gapless_enabled.store(enabled, Ordering::Relaxed);
} }
// ─── Device-change watcher ────────────────────────────────────────────────────
//
// Polls the OS default output device every 3 s. When it changes (Bluetooth
// headphones connecting, USB DAC plugging in, etc.) the stream is reopened on
// the new device and `audio:device-changed` is emitted so the frontend can
// restart playback. The old Sink is dropped here — it was bound to the
// now-closed OutputStream and can no longer produce audio on any device.
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let reopen_tx = engine.stream_reopen_tx.clone();
let stream_handle = engine.stream_handle.clone();
let stream_rate = engine.stream_sample_rate.clone();
let current = engine.current.clone();
let fading_out = engine.fading_out_sink.clone();
tauri::async_runtime::spawn(async move {
let mut last_name: Option<String> = tauri::async_runtime::spawn_blocking(|| {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.name().ok())
}).await.unwrap_or(None);
loop {
tokio::time::sleep(Duration::from_secs(3)).await;
let current_name: Option<String> = tauri::async_runtime::spawn_blocking(|| {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.name().ok())
}).await.unwrap_or(None);
if current_name == last_name {
continue;
}
last_name = current_name.clone();
// Only act if there is actually a device to open.
let Some(_new_name) = current_name else { continue };
// Debounce: give the OS time to finish configuring the new device.
tokio::time::sleep(Duration::from_millis(500)).await;
let rate = stream_rate.load(Ordering::Relaxed);
let reopen_tx2 = reopen_tx.clone();
let new_handle = tauri::async_runtime::spawn_blocking(move || {
let (reply_tx, reply_rx) =
std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
if reopen_tx2.send((rate, false, reply_tx)).is_err() {
return None; // audio thread exited
}
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
}).await.unwrap_or(None);
let Some(handle) = new_handle else {
eprintln!("[psysonic] device-watcher: stream reopen timed out");
continue;
};
*stream_handle.lock().unwrap() = handle;
// Drop the old Sink — it was bound to the now-closed OutputStream.
if let Some(s) = current.lock().unwrap().sink.take() { s.stop(); }
if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); }
app.emit("audio:device-changed", ()).ok();
}
});
}
+7
View File
@@ -1386,6 +1386,13 @@ pub fn run() {
} }
} }
// ── Audio device-change watcher ───────────────────────────────
{
use tauri::Manager;
let engine = app.state::<audio::AudioEngine>();
audio::start_device_watcher(&engine, app.handle().clone());
}
Ok(()) Ok(())
}) })
.on_window_event(|window, event| { .on_window_event(|window, event| {
+22
View File
@@ -413,6 +413,28 @@ function TauriEventBridge() {
return () => { unlisten?.(); }; return () => { unlisten?.(); };
}, []); }, []);
// Audio output device changed (Bluetooth headphones, USB DAC, etc.)
// The Rust device-watcher has already reopened the stream on the new device
// and dropped the old Sink, so we just need to restart playback.
useEffect(() => {
let unlisten: (() => void) | undefined;
listen('audio:device-changed', () => {
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return;
if (isPlaying) {
const pos = currentTime;
const dur = currentTrack.duration || 1;
playTrack(currentTrack);
setTimeout(() => usePlayerStore.getState().seek(pos / dur), 600);
} 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.
resetAudioPause();
}
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
// Sync tray-icon visibility with the user's stored setting. // Sync tray-icon visibility with the user's stored setting.
// Runs once on mount (initial sync) and again whenever the setting changes. // Runs once on mount (initial sync) and again whenever the setting changes.
const showTrayIcon = useAuthStore(s => s.showTrayIcon); const showTrayIcon = useAuthStore(s => s.showTrayIcon);
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Search, Disc3, Users, Music, SlidersHorizontal } from 'lucide-react'; import { Search, Disc3, Users, Music, SlidersVertical } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic'; import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
@@ -126,7 +126,7 @@ export default function LiveSearch() {
data-tooltip-pos="bottom" data-tooltip-pos="bottom"
aria-label={t('search.advanced')} aria-label={t('search.advanced')}
> >
<SlidersHorizontal size={14} /> <SlidersVertical size={14} />
</button> </button>
</div> </div>
+2 -2
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal, Cast Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, MicVocal, Cast
} from 'lucide-react'; } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
@@ -241,7 +241,7 @@ export default function PlayerBar() {
aria-label="Equalizer" aria-label="Equalizer"
data-tooltip="Equalizer" data-tooltip="Equalizer"
> >
<SlidersHorizontal size={15} /> <SlidersVertical size={15} />
</button> </button>
{/* Volume */} {/* Volume */}
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { Play, SlidersHorizontal } from 'lucide-react'; import { Play, SlidersVertical } from 'lucide-react';
import { import {
search, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs, search, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs,
SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong,
@@ -140,7 +140,7 @@ export default function AdvancedSearch() {
<div className="content-body animate-fade-in"> <div className="content-body animate-fade-in">
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<SlidersHorizontal size={22} style={{ color: 'var(--accent)', flexShrink: 0 }} /> <SlidersVertical size={22} style={{ color: 'var(--accent)', flexShrink: 0 }} />
{t('search.advanced')} {t('search.advanced')}
</h1> </h1>
</div> </div>
+5
View File
@@ -117,6 +117,7 @@ interface PlayerState {
setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void; setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void;
syncLastfmLovedTracks: () => Promise<void>; syncLastfmLovedTracks: () => Promise<void>;
resetAudioPause: () => void;
initializeFromServerQueue: () => Promise<void>; initializeFromServerQueue: () => Promise<void>;
contextMenu: { contextMenu: {
@@ -863,6 +864,10 @@ export const usePlayerStore = create<PlayerState>()(
set({ isPlaying: false }); set({ isPlaying: false });
}, },
resetAudioPause: () => {
isAudioPaused = false;
},
resume: () => { resume: () => {
if (get().currentRadio) { if (get().currentRadio) {
radioAudio.play().catch(console.error); radioAudio.play().catch(console.error);