mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
merge: integrate origin/main into fix/statistics-i18n-small-fixes
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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,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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user