refactor(v2): enforce backend purity and stabilize playback runtime

cx
This commit is contained in:
vicrodh committed 2026-02-16 07:45:43 -06:00
1 parent d64e0f38d7
commit 8ac5ba92fd
12 files changed
+427 -102

No files matched your search

+23 -1
View File
@@ -164,7 +164,8 @@ impl QueueManager {
state.shuffle_order.clear(); state.shuffle_order.clear();
state.shuffle_position = 0; state.shuffle_position = 0;
state.history.clear(); // Keep playback history when clearing queue.
// UX expectation: "Clear queue" only affects current/upcoming queue items.
} }
/// Remove a track by index /// Remove a track by index
@@ -676,6 +677,27 @@ mod tests {
assert_eq!(state.total_tracks, 1); assert_eq!(state.total_tracks, 1);
} }
#[test]
fn test_clear_preserves_history() {
let queue = QueueManager::new();
queue.add_track(create_test_track(123));
queue.add_track(create_test_track(124));
queue.add_track(create_test_track(125));
queue.play_index(0);
queue.next(); // push 123 into history, current becomes 124
let before = queue.get_state();
assert_eq!(before.history.len(), 1);
assert_eq!(before.history[0].id, 123);
queue.clear();
let after = queue.get_state();
assert_eq!(after.history.len(), 1);
assert_eq!(after.history[0].id, 123);
}
#[test] #[test]
fn test_move_track_down_without_current_track() { fn test_move_track_down_without_current_track() {
let queue = QueueManager::new(); let queue = QueueManager::new();
+277 -13
View File
@@ -17,24 +17,28 @@ use qbz_models::{
use crate::artist_blacklist::BlacklistState; use crate::artist_blacklist::BlacklistState;
use crate::artist_vectors::ArtistVectorStoreState; use crate::artist_vectors::ArtistVectorStoreState;
use crate::cache::AudioCache; use crate::cache::{AudioCache, CacheStats};
use crate::api_cache::ApiCacheState; use crate::api_cache::ApiCacheState;
use crate::audio::{AlsaPlugin, AudioBackendType}; use crate::audio::{AlsaPlugin, AudioBackendType, AudioDevice, BackendManager};
use crate::cast::{AirPlayMetadata, AirPlayState, CastState, DlnaMetadata, DlnaState, MediaMetadata}; use crate::cast::{AirPlayMetadata, AirPlayState, CastState, DlnaMetadata, DlnaState, MediaMetadata};
use crate::config::audio_settings::{AudioSettings, AudioSettingsState}; use crate::config::audio_settings::{AudioSettings, AudioSettingsState};
use crate::config::developer_settings::{DeveloperSettings, DeveloperSettingsState}; use crate::config::developer_settings::{DeveloperSettings, DeveloperSettingsState};
use crate::config::download_settings::DownloadSettingsState;
use crate::config::favorites_preferences::FavoritesPreferences;
use crate::config::graphics_settings::{GraphicsSettings, GraphicsSettingsState, GraphicsStartupStatus}; use crate::config::graphics_settings::{GraphicsSettings, GraphicsSettingsState, GraphicsStartupStatus};
use crate::config::legal_settings::LegalSettingsState; use crate::config::legal_settings::LegalSettingsState;
use crate::config::playback_preferences::{AutoplayMode, PlaybackPreferencesState}; use crate::config::playback_preferences::{AutoplayMode, PlaybackPreferences, PlaybackPreferencesState};
use crate::config::tray_settings::TraySettings;
use crate::config::tray_settings::TraySettingsState; use crate::config::tray_settings::TraySettingsState;
use crate::config::window_settings::WindowSettingsState; use crate::config::window_settings::WindowSettingsState;
use crate::core_bridge::CoreBridgeState; use crate::core_bridge::CoreBridgeState;
use crate::library::{thumbnails, LibraryState, MetadataExtractor, get_artwork_cache_dir}; use crate::library::{thumbnails, LibraryState, LocalAlbum, MetadataExtractor, PlaylistLocalTrack, PlaylistSettings, PlaylistStats, get_artwork_cache_dir};
use crate::lyrics::LyricsState; use crate::lyrics::LyricsState;
use crate::musicbrainz::{CacheStats as MusicBrainzCacheStats, MusicBrainzSharedState}; use crate::musicbrainz::{CacheStats as MusicBrainzCacheStats, MusicBrainzSharedState};
use crate::offline::OfflineState; use crate::offline::OfflineState;
use crate::offline_cache::OfflineCacheState; use crate::offline_cache::OfflineCacheState;
use crate::playback_context::{ContentSource, ContextType, PlaybackContext}; use crate::playback_context::{ContentSource, ContextType, PlaybackContext};
use crate::plex::{PlexMusicSection, PlexPlayResult, PlexServerInfo, PlexTrack};
use crate::queue::QueueTrack; use crate::queue::QueueTrack;
use crate::reco_store::{RecoEventInput, RecoState}; use crate::reco_store::{RecoEventInput, RecoState};
use crate::runtime::{RuntimeManagerState, RuntimeStatus, RuntimeError, RuntimeEvent, DegradedReason, CommandRequirement}; use crate::runtime::{RuntimeManagerState, RuntimeStatus, RuntimeError, RuntimeEvent, DegradedReason, CommandRequirement};
@@ -48,6 +52,23 @@ use std::sync::Mutex;
// ==================== Helper Functions ==================== // ==================== Helper Functions ====================
/// Backend information for UI display
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BackendInfo {
pub backend_type: AudioBackendType,
pub name: String,
pub description: String,
pub is_available: bool,
}
/// ALSA plugin information for UI display
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AlsaPluginInfo {
pub plugin: AlsaPlugin,
pub name: String,
pub description: String,
}
/// Check that Qobuz ToS has been accepted before allowing login. /// Check that Qobuz ToS has been accepted before allowing login.
/// ///
/// This is the single enforcement point for ToS gate in backend. /// This is the single enforcement point for ToS gate in backend.
@@ -1032,7 +1053,13 @@ pub fn v2_set_enable_tray(
value: bool, value: bool,
state: State<'_, TraySettingsState>, state: State<'_, TraySettingsState>,
) -> Result<(), String> { ) -> Result<(), String> {
state.set_enable_tray(value) state.set_enable_tray(value)?;
// Mirror to global startup store so tray visibility on next launch
// is consistent even before session activation/runtime bootstrap.
if let Ok(global_store) = crate::config::tray_settings::TraySettingsStore::new() {
let _ = global_store.set_enable_tray(value);
}
Ok(())
} }
#[tauri::command] #[tauri::command]
@@ -1067,6 +1094,109 @@ pub fn v2_set_show_context_icon(
state.set_show_context_icon(show) state.set_show_context_icon(show)
} }
#[tauri::command]
pub fn v2_get_playback_preferences(
state: State<'_, PlaybackPreferencesState>,
) -> Result<PlaybackPreferences, String> {
state.get_preferences()
}
#[tauri::command]
pub fn v2_get_tray_settings(
state: State<'_, TraySettingsState>,
) -> Result<TraySettings, String> {
state.get_settings()
}
#[tauri::command]
pub fn v2_get_favorites_preferences(
state: State<'_, crate::config::favorites_preferences::FavoritesPreferencesState>,
) -> Result<FavoritesPreferences, String> {
let guard = state
.store
.lock()
.map_err(|_| "Failed to lock favorites preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_preferences()
}
#[tauri::command]
pub fn v2_get_cache_stats(state: State<'_, AppState>) -> CacheStats {
state.audio_cache.stats()
}
#[tauri::command]
pub fn v2_get_available_backends() -> Result<Vec<BackendInfo>, String> {
log::info!("Command: v2_get_available_backends");
let backends = BackendManager::available_backends();
let backend_infos: Vec<BackendInfo> = backends
.into_iter()
.map(|backend_type| {
let backend = BackendManager::create_backend(backend_type);
let (is_available, description) = match backend {
Ok(b) => (b.is_available(), b.description().to_string()),
Err(_) => (false, "Not available".to_string()),
};
let name = match backend_type {
AudioBackendType::PipeWire => "PipeWire",
AudioBackendType::Alsa => "ALSA Direct",
AudioBackendType::Pulse => "PulseAudio",
};
BackendInfo {
backend_type,
name: name.to_string(),
description,
is_available,
}
})
.collect();
Ok(backend_infos)
}
#[tauri::command]
#[allow(non_snake_case)]
pub fn v2_get_devices_for_backend(
backendType: AudioBackendType,
) -> Result<Vec<AudioDevice>, String> {
log::info!("Command: v2_get_devices_for_backend({:?})", backendType);
let backend = BackendManager::create_backend(backendType)?;
backend.enumerate_devices()
}
#[tauri::command]
pub fn v2_get_alsa_plugins() -> Result<Vec<AlsaPluginInfo>, String> {
Ok(vec![
AlsaPluginInfo {
plugin: AlsaPlugin::Hw,
name: "hw (Direct Hardware)".to_string(),
description: "Bit-perfect, exclusive access, blocks device for other apps".to_string(),
},
AlsaPluginInfo {
plugin: AlsaPlugin::PlugHw,
name: "plughw (Plugin Hardware)".to_string(),
description: "Automatic format conversion, still relatively direct".to_string(),
},
AlsaPluginInfo {
plugin: AlsaPlugin::Pcm,
name: "pcm (Default)".to_string(),
description: "Generic ALSA device, most compatible".to_string(),
},
])
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_plex_ping(
baseUrl: String,
token: String,
) -> Result<PlexServerInfo, String> {
crate::plex::plex_ping(baseUrl, token).await
}
#[tauri::command] #[tauri::command]
pub fn v2_set_visualizer_enabled( pub fn v2_set_visualizer_enabled(
enabled: bool, enabled: bool,
@@ -1688,8 +1818,15 @@ pub async fn v2_library_clear_thumbnails_cache() -> Result<u64, String> {
pub async fn v2_library_play_track( pub async fn v2_library_play_track(
track_id: i64, track_id: i64,
library_state: State<'_, LibraryState>, library_state: State<'_, LibraryState>,
app_state: State<'_, AppState>, bridge: State<'_, CoreBridgeState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<(), String> { ) -> Result<(), String> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresUserSession)
.await
.map_err(|e| e.to_string())?;
let track = { let track = {
let guard = library_state.db.lock().await; let guard = library_state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?; let db = guard.as_ref().ok_or("No active session - please log in")?;
@@ -1702,16 +1839,17 @@ pub async fn v2_library_play_track(
return Err(format!("File not found: {}", track.file_path)); return Err(format!("File not found: {}", track.file_path));
} }
let audio_data = std::fs::read(file_path).map_err(|e| format!("Failed to read file: {}", e))?; let audio_data = std::fs::read(file_path).map_err(|e| format!("Failed to read file: {}", e))?;
app_state let bridge = bridge.get().await;
.player bridge
.player()
.play_data(audio_data, track_id as u64) .play_data(audio_data, track_id as u64)
.map_err(|e| format!("Failed to play: {}", e))?; .map_err(|e| format!("Failed to play: {}", e))?;
if let Some(start_secs) = track.cue_start_secs { if let Some(start_secs) = track.cue_start_secs {
let start_pos = start_secs as u64; let start_pos = start_secs as u64;
if start_pos > 0 { if start_pos > 0 {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
app_state bridge
.player .player()
.seek(start_pos) .seek(start_pos)
.map_err(|e| format!("Failed to seek: {}", e))?; .map_err(|e| format!("Failed to seek: {}", e))?;
} }
@@ -2353,6 +2491,127 @@ pub async fn v2_library_get_cache_stats() -> Result<V2LibraryCacheStats, String>
}) })
} }
#[tauri::command]
pub async fn v2_playlist_get_all_settings(
state: State<'_, LibraryState>,
) -> Result<Vec<PlaylistSettings>, String> {
let guard__ = state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?;
db.get_all_playlist_settings().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_playlist_get_favorites(
state: State<'_, LibraryState>,
) -> Result<Vec<u64>, String> {
let guard__ = state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?;
db.get_favorite_playlist_ids().map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_playlist_get_local_tracks_with_position(
playlistId: u64,
state: State<'_, LibraryState>,
) -> Result<Vec<PlaylistLocalTrack>, String> {
let guard__ = state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?;
db.get_playlist_local_tracks_with_position(playlistId)
.map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_playlist_get_settings(
playlistId: u64,
state: State<'_, LibraryState>,
) -> Result<Option<PlaylistSettings>, String> {
let guard__ = state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?;
db.get_playlist_settings(playlistId).map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_playlist_get_stats(
playlistId: u64,
state: State<'_, LibraryState>,
) -> Result<Option<PlaylistStats>, String> {
let guard__ = state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?;
db.get_playlist_stats(playlistId).map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_playlist_increment_play_count(
playlistId: u64,
state: State<'_, LibraryState>,
) -> Result<PlaylistStats, String> {
let guard__ = state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?;
db.increment_playlist_play_count(playlistId)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_library_get_albums(
include_hidden: Option<bool>,
exclude_network_folders: Option<bool>,
state: State<'_, LibraryState>,
download_settings_state: State<'_, DownloadSettingsState>,
) -> Result<Vec<LocalAlbum>, String> {
let include_qobuz = download_settings_state
.lock()
.map_err(|e| format!("Failed to lock download settings: {}", e))?
.as_ref()
.and_then(|s| s.get_settings().ok())
.map(|s| s.show_in_library)
.unwrap_or(false);
let guard__ = state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?;
db.get_albums_with_full_filter(
include_hidden.unwrap_or(false),
include_qobuz,
exclude_network_folders.unwrap_or(false),
)
.map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_plex_get_music_sections(
baseUrl: String,
token: String,
) -> Result<Vec<PlexMusicSection>, String> {
crate::plex::plex_get_music_sections(baseUrl, token).await
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_plex_get_section_tracks(
baseUrl: String,
token: String,
sectionKey: String,
limit: Option<u32>,
) -> Result<Vec<PlexTrack>, String> {
crate::plex::plex_get_section_tracks(baseUrl, token, sectionKey, limit).await
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_plex_play_track(
baseUrl: String,
token: String,
ratingKey: String,
app_state: State<'_, AppState>,
) -> Result<PlexPlayResult, String> {
crate::plex::plex_play_track(baseUrl, token, ratingKey, app_state).await
}
#[tauri::command] #[tauri::command]
pub async fn v2_library_update_folder_path( pub async fn v2_library_update_folder_path(
id: i64, id: i64,
@@ -3059,8 +3318,8 @@ pub async fn v2_get_artist(
#[allow(non_snake_case)] #[allow(non_snake_case)]
pub async fn v2_get_favorites( pub async fn v2_get_favorites(
favType: String, favType: String,
limit: u32, limit: Option<u32>,
offset: u32, offset: Option<u32>,
bridge: State<'_, CoreBridgeState>, bridge: State<'_, CoreBridgeState>,
runtime: State<'_, RuntimeManagerState>, runtime: State<'_, RuntimeManagerState>,
) -> Result<serde_json::Value, RuntimeError> { ) -> Result<serde_json::Value, RuntimeError> {
@@ -3068,7 +3327,12 @@ pub async fn v2_get_favorites(
runtime.manager().check_requirements(CommandRequirement::RequiresCoreBridgeAuth).await?; runtime.manager().check_requirements(CommandRequirement::RequiresCoreBridgeAuth).await?;
let bridge = bridge.get().await; let bridge = bridge.get().await;
bridge.get_favorites(&favType, limit, offset).await.map_err(RuntimeError::Internal) let resolved_limit = limit.unwrap_or(500);
let resolved_offset = offset.unwrap_or(0);
bridge
.get_favorites(&favType, resolved_limit, resolved_offset)
.await
.map_err(RuntimeError::Internal)
} }
/// Add item to favorites (V2 - uses QbzCore) /// Add item to favorites (V2 - uses QbzCore)
+4 -4
View File
@@ -17,7 +17,7 @@ use qbz_models::{
QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, UserSession, QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, UserSession,
}; };
use qbz_player::{Player, PlaybackState}; use qbz_player::{Player, PlaybackState};
use qbz_audio::{AudioSettings, AudioDiagnostic, settings::AudioSettingsStore}; use qbz_audio::{AudioSettings, AudioDiagnostic, VisualizerTap, settings::AudioSettingsStore};
use crate::tauri_adapter::TauriAdapter; use crate::tauri_adapter::TauriAdapter;
@@ -35,7 +35,7 @@ impl CoreBridge {
/// Loads audio settings from qbz_audio::AudioSettingsStore, creates a Player /// Loads audio settings from qbz_audio::AudioSettingsStore, creates a Player
/// from qbz-player crate, and passes it to QbzCore. /// from qbz-player crate, and passes it to QbzCore.
/// This is the V2 architecture - playback goes through QbzCore. /// This is the V2 architecture - playback goes through QbzCore.
pub async fn new(adapter: TauriAdapter) -> Result<Self, String> { pub async fn new(adapter: TauriAdapter, visualizer_tap: Option<VisualizerTap>) -> Result<Self, String> {
// Load audio settings using qbz_audio store // Load audio settings using qbz_audio store
let (device_name, audio_settings) = AudioSettingsStore::new() let (device_name, audio_settings) = AudioSettingsStore::new()
.ok() .ok()
@@ -58,7 +58,7 @@ impl CoreBridge {
// Create Player from qbz-player crate // Create Player from qbz-player crate
let diagnostic = AudioDiagnostic::new(); let diagnostic = AudioDiagnostic::new();
let player = Player::new(device_name, audio_settings, None, diagnostic); let player = Player::new(device_name, audio_settings, visualizer_tap, diagnostic);
// Create QbzCore with the player // Create QbzCore with the player
let core = QbzCore::new(adapter, player); let core = QbzCore::new(adapter, player);
@@ -542,7 +542,7 @@ impl CoreBridgeState {
/// Initialize the core bridge with the app handle /// Initialize the core bridge with the app handle
pub async fn init(&self, adapter: TauriAdapter) -> Result<(), String> { pub async fn init(&self, adapter: TauriAdapter) -> Result<(), String> {
let bridge = CoreBridge::new(adapter).await?; let bridge = CoreBridge::new(adapter, None).await?;
*self.0.write().await = Some(bridge); *self.0.write().await = Some(bridge);
Ok(()) Ok(())
} }
+54 -14
View File
@@ -199,10 +199,29 @@ pub fn run() {
audio_settings.preferred_sample_rate audio_settings.preferred_sample_rate
); );
// Read tray settings from flat path once for tray initialization. // Read tray settings for startup tray initialization.
let tray_settings = config::tray_settings::TraySettingsStore::new() // Prefer last active user-scoped settings, then fallback to global flat path.
.and_then(|store| store.get_settings()) let tray_settings = if let Some(last_uid) = user_data::UserDataPaths::load_last_user_id() {
.unwrap_or_default(); let user_settings = dirs::data_dir()
.map(|d| d.join("qbz").join("users").join(last_uid.to_string()))
.and_then(|user_dir| {
config::tray_settings::TraySettingsStore::new_at(&user_dir)
.and_then(|store| store.get_settings())
.ok()
});
if let Some(settings) = user_settings {
log::info!("Loaded tray settings from last active user {}", last_uid);
settings
} else {
config::tray_settings::TraySettingsStore::new()
.and_then(|store| store.get_settings())
.unwrap_or_default()
}
} else {
config::tray_settings::TraySettingsStore::new()
.and_then(|store| store.get_settings())
.unwrap_or_default()
};
log::info!( log::info!(
"Tray settings: enable={}, minimize_to_tray={}, close_to_tray={}", "Tray settings: enable={}, minimize_to_tray={}, close_to_tray={}",
tray_settings.enable_tray, tray_settings.enable_tray,
@@ -361,8 +380,14 @@ pub fn run() {
{ {
let core_bridge_arc = app.state::<core_bridge::CoreBridgeState>().0.clone(); let core_bridge_arc = app.state::<core_bridge::CoreBridgeState>().0.clone();
let adapter = tauri_adapter::TauriAdapter::new(app.handle().clone()); let adapter = tauri_adapter::TauriAdapter::new(app.handle().clone());
let v1_viz_tap = app.state::<AppState>().visualizer.get_tap();
let v2_viz_tap = qbz_audio::VisualizerTap {
ring_buffer: v1_viz_tap.ring_buffer.clone(),
enabled: v1_viz_tap.enabled.clone(),
sample_rate: v1_viz_tap.sample_rate.clone(),
};
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let bridge = core_bridge::CoreBridge::new(adapter).await; let bridge = core_bridge::CoreBridge::new(adapter, Some(v2_viz_tap)).await;
match bridge { match bridge {
Ok(b) => { Ok(b) => {
// Store V2 player state for event loop // Store V2 player state for event loop
@@ -389,18 +414,15 @@ pub fn run() {
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
let legacy_player_state = app.state::<AppState>().player.state.clone(); let legacy_player_state = app.state::<AppState>().player.state.clone();
std::thread::spawn(move || { tauri::async_runtime::spawn(async move {
let mut last_position: u64 = 0; let mut last_position: u64 = 0;
let mut last_is_playing: bool = false; let mut last_is_playing: bool = false;
let mut last_track_id: u64 = 0; let mut last_track_id: u64 = 0;
let rt = tokio::runtime::Handle::current();
loop { loop {
// Check V2 player state first (takes priority if active) // Check V2 player state first (takes priority if active)
// V2 player is accessed via async lock, but we only need to check if it has a track // V2 player is accessed via async lock, but we only need a clone
let v2_state_opt: Option<qbz_player::SharedState> = rt.block_on(async { let v2_state_opt: Option<qbz_player::SharedState> = v2_player_state.read().await.clone();
v2_player_state.read().await.clone()
});
// Determine which player is active (V2 takes priority if it has a track) // Determine which player is active (V2 takes priority if it has a track)
let (is_playing, track_id, position, duration, volume, sample_rate, bit_depth, normalization_gain, gapless_ready, gapless_next_track_id) = let (is_playing, track_id, position, duration, volume, sample_rate, bit_depth, normalization_gain, gapless_ready, gapless_next_track_id) =
@@ -452,17 +474,17 @@ pub fn run() {
}; };
// Adaptive polling: // Adaptive polling:
// - fast (500ms) when playing - balances UI responsiveness vs CPU // - fast (250ms) when playing - improves seekbar/lyrics sync
// - slow (1000ms) when paused/stopped with a track loaded // - slow (1000ms) when paused/stopped with a track loaded
// - very slow (5000ms) when no track is loaded (idle) // - very slow (5000ms) when no track is loaded (idle)
let sleep_duration = if is_playing { let sleep_duration = if is_playing {
std::time::Duration::from_millis(500) std::time::Duration::from_millis(250)
} else if track_id == 0 { } else if track_id == 0 {
std::time::Duration::from_millis(5000) std::time::Duration::from_millis(5000)
} else { } else {
std::time::Duration::from_millis(1000) std::time::Duration::from_millis(1000)
}; };
std::thread::sleep(sleep_duration); tokio::time::sleep(sleep_duration).await;
// Only emit if state changed or position advanced // Only emit if state changed or position advanced
let should_emit = track_id != 0 && ( let should_emit = track_id != 0 && (
@@ -1146,8 +1168,19 @@ pub fn run() {
commands_v2::v2_set_enable_tray, commands_v2::v2_set_enable_tray,
commands_v2::v2_set_minimize_to_tray, commands_v2::v2_set_minimize_to_tray,
commands_v2::v2_set_close_to_tray, commands_v2::v2_set_close_to_tray,
commands_v2::v2_get_tray_settings,
commands_v2::v2_set_autoplay_mode, commands_v2::v2_set_autoplay_mode,
commands_v2::v2_set_show_context_icon, commands_v2::v2_set_show_context_icon,
commands_v2::v2_get_playback_preferences,
commands_v2::v2_get_favorites_preferences,
commands_v2::v2_get_cache_stats,
commands_v2::v2_get_available_backends,
commands_v2::v2_get_devices_for_backend,
commands_v2::v2_get_alsa_plugins,
commands_v2::v2_plex_ping,
commands_v2::v2_plex_get_music_sections,
commands_v2::v2_plex_get_section_tracks,
commands_v2::v2_plex_play_track,
commands_v2::v2_set_visualizer_enabled, commands_v2::v2_set_visualizer_enabled,
commands_v2::v2_get_developer_settings, commands_v2::v2_get_developer_settings,
commands_v2::v2_set_developer_force_dmabuf, commands_v2::v2_set_developer_force_dmabuf,
@@ -1204,6 +1237,12 @@ pub fn run() {
commands_v2::v2_library_play_track, commands_v2::v2_library_play_track,
commands_v2::v2_playlist_set_sort, commands_v2::v2_playlist_set_sort,
commands_v2::v2_playlist_set_artwork, commands_v2::v2_playlist_set_artwork,
commands_v2::v2_playlist_get_all_settings,
commands_v2::v2_playlist_get_favorites,
commands_v2::v2_playlist_get_local_tracks_with_position,
commands_v2::v2_playlist_get_settings,
commands_v2::v2_playlist_get_stats,
commands_v2::v2_playlist_increment_play_count,
commands_v2::v2_playlist_add_local_track, commands_v2::v2_playlist_add_local_track,
commands_v2::v2_playlist_remove_local_track, commands_v2::v2_playlist_remove_local_track,
commands_v2::v2_playlist_set_hidden, commands_v2::v2_playlist_set_hidden,
@@ -1241,6 +1280,7 @@ pub fn run() {
commands_v2::v2_reco_log_event, commands_v2::v2_reco_log_event,
commands_v2::v2_reco_train_scores, commands_v2::v2_reco_train_scores,
commands_v2::v2_library_get_cache_stats, commands_v2::v2_library_get_cache_stats,
commands_v2::v2_library_get_albums,
commands_v2::v2_library_update_folder_path, commands_v2::v2_library_update_folder_path,
commands_v2::v2_library_cache_artist_image, commands_v2::v2_library_cache_artist_image,
commands_v2::v2_library_set_custom_artist_image, commands_v2::v2_library_set_custom_artist_image,
+11 -4
View File
@@ -449,10 +449,11 @@ pub async fn library_check_folder_accessible(path: String) -> Result<bool, Strin
return Ok(false); return Ok(false);
} }
// Try to read the directory with a timeout to avoid hanging on network paths // Try to read the directory with a timeout to avoid hanging on network paths.
// Network shares can be slow to answer, so use a less aggressive timeout.
let path_clone = path.clone(); let path_clone = path.clone();
let check_result = tokio::time::timeout( let check_result = tokio::time::timeout(
std::time::Duration::from_secs(2), std::time::Duration::from_secs(6),
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
std::fs::read_dir(Path::new(&path_clone)).is_ok() std::fs::read_dir(Path::new(&path_clone)).is_ok()
}) })
@@ -465,8 +466,14 @@ pub async fn library_check_folder_accessible(path: String) -> Result<bool, Strin
Ok(false) Ok(false)
}, },
Err(_) => { Err(_) => {
log::warn!("Timeout checking folder accessibility (likely unreachable network path): {}", path); // If the folder still exists, treat it as accessible to avoid false negatives
Ok(false) // in mounted-but-slow network shares.
let exists = Path::new(&path).exists();
log::warn!(
"Timeout checking folder accessibility: {} (exists={})",
path, exists
);
Ok(exists)
} }
} }
} }
+2 -1
View File
@@ -242,7 +242,8 @@ impl QueueManager {
state.shuffle_order.clear(); state.shuffle_order.clear();
state.shuffle_position = 0; state.shuffle_position = 0;
state.history.clear(); // Keep playback history when clearing queue.
// UX expectation: "Clear queue" only affects current/upcoming queue items.
} }
/// Remove a track by index /// Remove a track by index
+1 -2
View File
@@ -12,8 +12,7 @@ use spectrum_analyzer::scaling::divide_by_N_sqrt;
use spectrum_analyzer::windows::hann_window; use spectrum_analyzer::windows::hann_window;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use super::ring_buffer::RingBuffer; use super::{NUM_BARS, FFT_SIZE, TARGET_FPS, RingBuffer};
use super::{NUM_BARS, FFT_SIZE, TARGET_FPS};
/// Shared state for the visualizer thread /// Shared state for the visualizer thread
pub struct VisualizerState { pub struct VisualizerState {
+5 -53
View File
@@ -4,16 +4,11 @@
//! Uses a lockless ring buffer to capture samples from the audio thread and processes them //! Uses a lockless ring buffer to capture samples from the audio thread and processes them
//! on a dedicated thread. //! on a dedicated thread.
mod ring_buffer;
mod fft_processor; mod fft_processor;
mod tapped_source; pub use qbz_audio::{RingBuffer, TappedSource, VisualizerTap};
pub use ring_buffer::RingBuffer;
pub use fft_processor::{VisualizerState, start_visualizer_thread}; pub use fft_processor::{VisualizerState, start_visualizer_thread};
pub use tapped_source::TappedSource;
use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use tauri::AppHandle; use tauri::AppHandle;
/// Number of frequency bins to send to frontend /// Number of frequency bins to send to frontend
@@ -21,54 +16,11 @@ use tauri::AppHandle;
pub const NUM_BARS: usize = 16; pub const NUM_BARS: usize = 16;
/// FFT size (must be power of 2) /// FFT size (must be power of 2)
/// 1024 is faster than 2048 and still gives ~43Hz resolution at 44.1kHz pub const FFT_SIZE: usize = qbz_audio::visualizer::FFT_SIZE;
pub const FFT_SIZE: usize = 1024;
/// Target frames per second for visualization updates /// Target frames per second for visualization updates
pub const TARGET_FPS: u64 = 30; pub const TARGET_FPS: u64 = 30;
/// Shared state for visualization that can be passed to the audio thread
#[derive(Clone)]
pub struct VisualizerTap {
/// Ring buffer for sample capture
pub ring_buffer: Arc<RingBuffer>,
/// Whether visualization is enabled
pub enabled: Arc<AtomicBool>,
/// Current sample rate
pub sample_rate: Arc<AtomicU32>,
}
impl VisualizerTap {
/// Create a new tap
pub fn new() -> Self {
Self {
ring_buffer: Arc::new(RingBuffer::new(FFT_SIZE * 2)),
enabled: Arc::new(AtomicBool::new(false)),
sample_rate: Arc::new(AtomicU32::new(44100)),
}
}
/// Check if visualization is enabled (fast atomic check)
#[inline]
pub fn is_enabled(&self) -> bool {
self.enabled.load(Ordering::Relaxed)
}
/// Push a sample (only if enabled)
#[inline]
pub fn push(&self, sample: f32) {
if self.is_enabled() {
self.ring_buffer.push(sample);
}
}
}
impl Default for VisualizerTap {
fn default() -> Self {
Self::new()
}
}
/// Manages the audio visualizer lifecycle /// Manages the audio visualizer lifecycle
pub struct Visualizer { pub struct Visualizer {
/// Shared tap state (given to Player for sample capture) /// Shared tap state (given to Player for sample capture)
@@ -107,7 +59,7 @@ impl Visualizer {
/// Enable or disable visualization /// Enable or disable visualization
pub fn set_enabled(&self, enabled: bool) { pub fn set_enabled(&self, enabled: bool) {
self.tap.enabled.store(enabled, Ordering::Relaxed); self.tap.set_enabled(enabled);
log::info!("Visualizer {}", if enabled { "enabled" } else { "disabled" }); log::info!("Visualizer {}", if enabled { "enabled" } else { "disabled" });
} }
@@ -118,7 +70,7 @@ impl Visualizer {
/// Update the sample rate (call when audio format changes) /// Update the sample rate (call when audio format changes)
pub fn set_sample_rate(&self, rate: u32) { pub fn set_sample_rate(&self, rate: u32) {
self.tap.sample_rate.store(rate, Ordering::Relaxed); self.tap.set_sample_rate(rate);
} }
} }
+8 -1
View File
@@ -187,7 +187,7 @@
return; return;
} }
// Save credentials if "Remember me" is checked // Persist credential preference explicitly on each successful login
if (rememberMe) { if (rememberMe) {
try { try {
await invoke('v2_save_credentials', { email, password }); await invoke('v2_save_credentials', { email, password });
@@ -196,6 +196,13 @@
console.error('Failed to save credentials:', saveErr); console.error('Failed to save credentials:', saveErr);
// Don't block login if saving fails // Don't block login if saving fails
} }
} else {
try {
await invoke('v2_clear_saved_credentials');
console.log('Saved credentials cleared (remember me disabled)');
} catch (clearErr) {
console.error('Failed to clear saved credentials:', clearErr);
}
} }
onLoginSuccess({ onLoginSuccess({
+7 -9
View File
@@ -132,21 +132,19 @@ export async function playTrack(
console.log('[Gapless] Transition mode — skipping stop/play, updating metadata only'); console.log('[Gapless] Transition mode — skipping stop/play, updating metadata only');
setIsPlaying(true); setIsPlaying(true);
} else { } else {
// For Qobuz tracks: stop current playback immediately and show buffering // Always stop local playback engine before starting a new local track.
// This prevents the previous track from continuing while we download // This prevents overlap when switching source types (qobuz/local/plex).
// Local tracks load instantly so they don't need this if (!isCasting()) {
if (!isLocal && !isCasting()) {
// Stop current playback immediately // Stop current playback immediately
try { try {
await invoke('v2_stop_playback'); await invoke('v2_stop_playback');
} catch { } catch {
// Ignore errors - player might not be playing // Ignore errors - player might not be playing
} }
// Show buffering indicator }
if (showLoadingToast) {
showToast(track.title, 'buffering'); // Show buffering indicator for network-backed playback.
} if (showLoadingToast && !isLocal) {
} else if (showLoadingToast && !isLocal) {
showToast(track.title, 'buffering'); showToast(track.title, 'buffering');
} }
+33
View File
@@ -550,6 +550,11 @@ export function setOnGaplessTransition(callback: (trackId: number) => Promise<vo
* Handle playback event from backend * Handle playback event from backend
*/ */
async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> { async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> {
const prevTrackId = currentTrack?.id ?? 0;
const prevDuration = duration;
const prevPosition = currentTime;
const prevWasPlaying = isPlaying;
// Gapless transition: backend changed track_id because gapless playback advanced // Gapless transition: backend changed track_id because gapless playback advanced
// Handle this BEFORE the external track change handler to prevent stale queue lookups // Handle this BEFORE the external track change handler to prevent stale queue lookups
const isGaplessTransition = event.track_id !== 0 const isGaplessTransition = event.track_id !== 0
@@ -620,6 +625,34 @@ async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> {
return; return;
} }
// Fallback end-of-track detection:
// some backend paths can emit a terminal stop with track_id=0 instead of
// a final same-track frame at duration. Treat as natural end only when
// previous progress was already at the tail and playback was active.
if (
event.track_id === 0 &&
prevTrackId !== 0 &&
prevDuration > 0 &&
prevPosition >= prevDuration - 2 &&
!event.is_playing &&
prevWasPlaying &&
!isAdvancingTrack &&
!queueEnded &&
onTrackEnded
) {
console.log('[Player] Track ended (fallback), advancing to next...');
isAdvancingTrack = true;
try {
await onTrackEnded();
} catch (err) {
console.error('[Player] Failed fallback auto-advance:', err);
} finally {
isAdvancingTrack = false;
}
return;
}
// Update playback state if track matches // Update playback state if track matches
if (event.track_id === currentTrack.id) { if (event.track_id === currentTrack.id) {
currentTime = event.position; currentTime = event.position;
+2
View File
@@ -224,6 +224,7 @@
playTrack, playTrack,
checkTrackFavorite, checkTrackFavorite,
toggleTrackFavorite, toggleTrackFavorite,
loadSystemNotificationsPreference,
showTrackNotification, showTrackNotification,
updateLastfmNowPlaying, updateLastfmNowPlaying,
cleanup as cleanupPlayback cleanup as cleanupPlayback
@@ -2366,6 +2367,7 @@
// Set up per-user localStorage scoping and migrate old keys // Set up per-user localStorage scoping and migrate old keys
setStorageUserId(info.userId); setStorageUserId(info.userId);
migrateLocalStorage(info.userId); migrateLocalStorage(info.userId);
loadSystemNotificationsPreference();
// Re-sync volume from the now-correct user-scoped localStorage key // Re-sync volume from the now-correct user-scoped localStorage key
await resyncPersistedVolume(); await resyncPersistedVolume();