refactor(v2): enforce backend purity and stabilize playback runtime
cx
This commit is contained in:
1 parent
d64e0f38d7
commit
8ac5ba92fd
12 files changed
+427
-102
No files matched your search
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user