Merge branch 'pre-release' into feature/artist-enrichment

This commit is contained in:
Victor RH committed 2026-01-25 19:15:19 -06:00
commit 3218c6c3ec
33 files changed
+3281 -119

No files matched your search

+90 -18
View File
@@ -195,17 +195,33 @@ impl QobuzClient {
// === Search endpoints ===
/// Search for albums
pub async fn search_albums(&self, query: &str, limit: u32, offset: u32) -> Result<SearchResultsPage<Album>> {
/// Optional search_type: "MainArtist", "Performer", "Composer", "Label", "ReleaseName"
pub async fn search_albums(
&self,
query: &str,
limit: u32,
offset: u32,
search_type: Option<&str>,
) -> Result<SearchResultsPage<Album>> {
let url = endpoints::build_url(paths::ALBUM_SEARCH);
let limit_str = limit.to_string();
let offset_str = offset.to_string();
let mut params: Vec<(&str, &str)> = vec![
("query", query),
("limit", &limit_str),
("offset", &offset_str),
];
if let Some(st) = search_type {
params.push(("type", st));
}
let response: Value = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.query(&[
("query", query),
("limit", &limit.to_string()),
("offset", &offset.to_string()),
])
.query(&params)
.send()
.await?
.json()
@@ -219,17 +235,33 @@ impl QobuzClient {
}
/// Search for tracks
pub async fn search_tracks(&self, query: &str, limit: u32, offset: u32) -> Result<SearchResultsPage<Track>> {
/// Optional search_type: "MainArtist", "Performer", "Composer", "Label", "ReleaseName"
pub async fn search_tracks(
&self,
query: &str,
limit: u32,
offset: u32,
search_type: Option<&str>,
) -> Result<SearchResultsPage<Track>> {
let url = endpoints::build_url(paths::TRACK_SEARCH);
let limit_str = limit.to_string();
let offset_str = offset.to_string();
let mut params: Vec<(&str, &str)> = vec![
("query", query),
("limit", &limit_str),
("offset", &offset_str),
];
if let Some(st) = search_type {
params.push(("type", st));
}
let response: Value = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.query(&[
("query", query),
("limit", &limit.to_string()),
("offset", &offset.to_string()),
])
.query(&params)
.send()
.await?
.json()
@@ -243,17 +275,33 @@ impl QobuzClient {
}
/// Search for artists
pub async fn search_artists(&self, query: &str, limit: u32, offset: u32) -> Result<SearchResultsPage<Artist>> {
/// Optional search_type: "MainArtist", "Performer", "Composer", "Label", "ReleaseName"
pub async fn search_artists(
&self,
query: &str,
limit: u32,
offset: u32,
search_type: Option<&str>,
) -> Result<SearchResultsPage<Artist>> {
let url = endpoints::build_url(paths::ARTIST_SEARCH);
let limit_str = limit.to_string();
let offset_str = offset.to_string();
let mut params: Vec<(&str, &str)> = vec![
("query", query),
("limit", &limit_str),
("offset", &offset_str),
];
if let Some(st) = search_type {
params.push(("type", st));
}
let response: Value = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.query(&[
("query", query),
("limit", &limit.to_string()),
("offset", &offset.to_string()),
])
.query(&params)
.send()
.await?
.json()
@@ -478,6 +526,30 @@ impl QobuzClient {
Ok(serde_json::from_value(response)?)
}
/// Get label by ID with albums
pub async fn get_label(&self, label_id: u64, limit: u32, offset: u32) -> Result<LabelDetail> {
let url = endpoints::build_url(paths::LABEL_GET);
let locale = self.locale().await;
let response: Value = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.query(&[
("label_id", label_id.to_string()),
("extra", "albums".to_string()),
("limit", limit.to_string()),
("offset", offset.to_string()),
("lang", locale),
])
.send()
.await?
.json()
.await?;
Ok(serde_json::from_value(response)?)
}
// === Authenticated endpoints ===
/// Get stream URL for a track (requires auth + signature)
+14 -1
View File
@@ -158,6 +158,8 @@ pub struct AlbumSummary {
pub title: String,
#[serde(default)]
pub image: ImageSet,
/// Label (if returned in track response)
pub label: Option<Label>,
}
/// Artist model
@@ -254,13 +256,24 @@ impl ImageSet {
}
}
/// Label model
/// Label model (basic reference)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Label {
pub id: u64,
pub name: String,
}
/// Label detail with albums
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LabelDetail {
pub id: u64,
pub name: String,
pub description: Option<String>,
pub image: Option<ImageSet>,
pub albums: Option<SearchResultsPage<Album>>,
pub albums_count: Option<u32>,
}
/// Genre model
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Genre {
+2
View File
@@ -40,6 +40,7 @@ pub struct AlbumInfo {
pub year: String,
pub release_date: Option<String>,
pub label: String,
pub label_id: Option<u64>,
pub genre: String,
pub quality: String,
pub track_count: u32,
@@ -100,6 +101,7 @@ fn album_to_info(album: &Album) -> AlbumInfo {
year,
release_date: album.release_date_original.clone(),
label: album.label.as_ref().map(|l| l.name.clone()).unwrap_or_default(),
label_id: album.label.as_ref().map(|l| l.id),
genre: album.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default(),
quality: format_quality(album.maximum_bit_depth, album.maximum_sampling_rate),
track_count: album.tracks_count.unwrap_or(0),
+2
View File
@@ -20,6 +20,7 @@ pub mod queue;
pub mod radio;
pub mod search;
pub mod share;
pub mod smart_playlists;
pub use audio_backends::*;
pub use audio_diagnostics::*;
@@ -39,3 +40,4 @@ pub use queue::*;
pub use radio::*;
pub use search::*;
pub use share::*;
pub use smart_playlists::*;
+354 -3
View File
@@ -7,6 +7,7 @@ use tokio::sync::Mutex;
use crate::api::client::QobuzClient;
use crate::api::models::Quality;
use crate::cache::AudioCache;
use crate::config::audio_settings::AudioSettingsState;
use crate::offline_cache::OfflineCacheState;
use crate::player::PlaybackState;
use crate::queue::QueueManager;
@@ -16,10 +17,12 @@ use crate::AppState;
#[tauri::command]
pub async fn play_track(
track_id: u64,
duration_secs: Option<u64>,
state: State<'_, AppState>,
offline_cache: State<'_, OfflineCacheState>,
audio_settings: State<'_, AudioSettingsState>,
) -> Result<(), String> {
log::info!("Command: play_track {}", track_id);
log::info!("Command: play_track {} (duration: {:?}s)", track_id, duration_secs);
// First check offline cache (persistent disk cache)
{
@@ -89,8 +92,20 @@ pub async fn play_track(
}
}
// Not in any cache - download and cache in memory
log::info!("Track {} not in any cache, streaming...", track_id);
// Not in any cache - check if streaming is enabled
log::info!("Track {} not in any cache, checking streaming setting...", track_id);
// Check if stream_first_track is enabled
let (stream_first_enabled, buffer_seconds) = {
let store = audio_settings.store.lock().map_err(|e| format!("Lock error: {}", e))?;
match store.get_settings() {
Ok(settings) => (settings.stream_first_track, settings.stream_buffer_seconds),
Err(e) => {
log::warn!("Failed to get audio settings, using defaults: {}", e);
(false, 3)
}
}
};
let client = state.client.lock().await;
@@ -102,6 +117,59 @@ pub async fn play_track(
log::info!("Got stream URL for track {}", track_id);
if stream_first_enabled {
// Use streaming playback - start playing before full download
log::info!("🚀 Streaming mode enabled - starting stream-first playback for track {}", track_id);
// Get content length, audio info, and measured speed via HEAD request
let stream_info = get_stream_info(&stream_url.url).await?;
log::info!(
"📊 Stream info: {:.2} MB, {}Hz, {} channels, {:.1} MB/s",
stream_info.content_length as f64 / (1024.0 * 1024.0),
stream_info.sample_rate,
stream_info.channels,
stream_info.speed_mbps
);
// Start streaming playback with dynamic buffer based on measured speed
// The player will use from_speed_mbps internally
let buffer_writer = state.player.play_streaming_dynamic(
track_id,
stream_info.sample_rate,
stream_info.channels,
stream_info.content_length,
stream_info.speed_mbps,
duration_secs.unwrap_or(0), // Use 0 if not provided
)?;
// Release client lock before spawning background download
drop(client);
// Spawn background task to download and push data to buffer
let url = stream_url.url.clone();
let cache_clone = cache.clone();
let content_len = stream_info.content_length;
tokio::spawn(async move {
match download_and_stream(&url, buffer_writer, track_id, cache_clone, content_len).await {
Ok(()) => log::info!("🎵 Track {} ready for instant replay from cache", track_id),
Err(e) => log::error!("Streaming download failed for track {}: {}", track_id, e),
}
});
// Prefetch next track in background
spawn_prefetch(
state.client.clone(),
state.audio_cache.clone(),
&state.queue,
);
return Ok(());
}
// Standard download path (streaming disabled)
log::info!("Standard download mode for track {}", track_id);
// Download the audio
let audio_data = download_audio(&stream_url.url).await?;
let data_size = audio_data.len();
@@ -215,6 +283,289 @@ async fn download_audio(url: &str) -> Result<Vec<u8>, String> {
Ok(bytes.to_vec())
}
/// Stream info including measured download speed
pub struct StreamInfo {
pub content_length: u64,
pub sample_rate: u32,
pub channels: u16,
pub speed_mbps: f64,
}
/// Get stream info (content length, sample rate, channels, speed) via HEAD request and initial bytes
async fn get_stream_info(url: &str) -> Result<StreamInfo, String> {
use std::time::{Duration, Instant};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
// First, do a HEAD request to get content length
let head_response = client
.head(url)
.header("User-Agent", "Mozilla/5.0")
.send()
.await
.map_err(|e| format!("Failed HEAD request: {}", e))?;
if !head_response.status().is_success() {
return Err(format!("HEAD request failed: {}", head_response.status()));
}
let content_length = head_response
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.ok_or_else(|| "No content-length header".to_string())?;
// Download first ~64KB to probe audio format and measure speed
// This is enough for FLAC/M4A headers
let start_time = Instant::now();
let range_response = client
.get(url)
.header("User-Agent", "Mozilla/5.0")
.header("Range", "bytes=0-65535")
.send()
.await
.map_err(|e| format!("Failed range request: {}", e))?;
if !range_response.status().is_success() && range_response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
return Err(format!("Range request failed: {}", range_response.status()));
}
let initial_bytes = range_response
.bytes()
.await
.map_err(|e| format!("Failed to read initial bytes: {}", e))?;
let elapsed = start_time.elapsed();
let bytes_downloaded = initial_bytes.len() as f64;
let speed_mbps = if elapsed.as_secs_f64() > 0.0 {
(bytes_downloaded / elapsed.as_secs_f64()) / (1024.0 * 1024.0)
} else {
10.0 // Assume fast if instant
};
log::info!(
"📶 Probe: {}KB in {:.0}ms = {:.1} MB/s",
initial_bytes.len() / 1024,
elapsed.as_millis(),
speed_mbps
);
// Try to extract audio format from initial bytes
let (sample_rate, channels) = extract_audio_format_from_header(&initial_bytes)?;
Ok(StreamInfo {
content_length,
sample_rate,
channels,
speed_mbps,
})
}
/// Extract sample rate and channels from audio file header
fn extract_audio_format_from_header(data: &[u8]) -> Result<(u32, u16), String> {
use std::io::{BufReader, Cursor};
use rodio::Decoder;
// Check if this is FLAC
if data.len() >= 4 && &data[0..4] == b"fLaC" {
// Parse FLAC STREAMINFO block
// FLAC format: "fLaC" + METADATA_BLOCK_HEADER (4 bytes) + STREAMINFO
if data.len() >= 26 {
// STREAMINFO starts at byte 8
// Bytes 10-12: sample rate (20 bits) + channels (3 bits) + bits per sample (5 bits)
let sr_high = ((data[18] as u32) << 12) | ((data[19] as u32) << 4) | ((data[20] as u32) >> 4);
let sample_rate = sr_high;
let channels = ((data[20] >> 1) & 0x07) + 1;
return Ok((sample_rate, channels as u16));
}
}
// Check if this is M4A/MP4 (ftyp box)
if data.len() >= 12 && &data[4..8] == b"ftyp" {
// M4A uses AAC or ALAC, try symphonia probe
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use symphonia::default::get_probe;
let cursor = Box::new(Cursor::new(data.to_vec())) as Box<dyn std::io::Read + Send + Sync>;
// For probing, we need to create a MediaSource
// Use a simple wrapper
struct ProbeSource {
inner: Cursor<Vec<u8>>,
len: u64,
}
impl std::io::Read for ProbeSource {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.inner.read(buf)
}
}
impl std::io::Seek for ProbeSource {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
impl symphonia::core::io::MediaSource for ProbeSource {
fn is_seekable(&self) -> bool { true }
fn byte_len(&self) -> Option<u64> { Some(self.len) }
}
let len = data.len() as u64;
let source = Box::new(ProbeSource { inner: Cursor::new(data.to_vec()), len });
let mss = MediaSourceStream::new(source, Default::default());
let mut hint = Hint::new();
hint.with_extension("m4a");
let format_opts = FormatOptions {
enable_gapless: true,
..Default::default()
};
if let Ok(probed) = get_probe().format(&hint, mss, &format_opts, &MetadataOptions::default()) {
if let Some(track) = probed.format.default_track() {
let sample_rate = track.codec_params.sample_rate.unwrap_or(44100);
let channels = track.codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
return Ok((sample_rate, channels));
}
}
// Default to common values for M4A
return Ok((44100, 2));
}
// Try rodio decoder for other formats
match Decoder::new(BufReader::new(Cursor::new(data.to_vec()))) {
Ok(decoder) => {
use rodio::Source;
Ok((decoder.sample_rate(), decoder.channels()))
}
Err(_) => {
// Default fallback
log::warn!("Could not determine audio format, using defaults (44100Hz, stereo)");
Ok((44100, 2))
}
}
}
/// Download audio chunks and stream them to the buffer writer
/// Also caches the complete data when download finishes
async fn download_and_stream(
url: &str,
writer: crate::player::BufferWriter,
track_id: u64,
cache: Arc<AudioCache>,
content_length: u64,
) -> Result<(), String> {
use std::time::{Duration, Instant};
use futures_util::StreamExt;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300)) // Longer timeout for streaming
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
log::info!("📥 Starting streaming download for track {} ({:.2} MB total)",
track_id,
content_length as f64 / (1024.0 * 1024.0)
);
let response = client
.get(url)
.header("User-Agent", "Mozilla/5.0")
.send()
.await
.map_err(|e| format!("Failed to start stream: {}", e))?;
if !response.status().is_success() {
return Err(format!("Stream request failed: {}", response.status()));
}
let mut all_data = Vec::with_capacity(content_length as usize);
let mut stream = response.bytes_stream();
let mut bytes_received = 0u64;
let start_time = Instant::now();
let mut last_log_time = Instant::now();
let mut last_log_bytes = 0u64;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| format!("Stream chunk error: {}", e))?;
bytes_received += chunk.len() as u64;
// Accumulate for caching
all_data.extend_from_slice(&chunk);
// Push to streaming buffer
if let Err(e) = writer.push_chunk(&chunk) {
log::error!("Failed to push chunk to buffer: {}", e);
}
// Log progress every ~500ms with speed info
let now = Instant::now();
if now.duration_since(last_log_time) >= Duration::from_millis(500) {
let elapsed_total = start_time.elapsed().as_secs_f64();
let elapsed_interval = now.duration_since(last_log_time).as_secs_f64();
let bytes_this_interval = bytes_received - last_log_bytes;
// Current speed (MB/s)
let current_speed = (bytes_this_interval as f64 / elapsed_interval) / (1024.0 * 1024.0);
// Average speed
let avg_speed = (bytes_received as f64 / elapsed_total) / (1024.0 * 1024.0);
// Progress percentage
let progress = (bytes_received as f64 / content_length as f64) * 100.0;
// ETA
let remaining_bytes = content_length.saturating_sub(bytes_received);
let eta_secs = if avg_speed > 0.0 {
remaining_bytes as f64 / (avg_speed * 1024.0 * 1024.0)
} else {
0.0
};
log::info!(
"📊 Download: {:.1}% ({:.2}/{:.2} MB) | Speed: {:.2} MB/s (avg: {:.2}) | ETA: {:.1}s",
progress,
bytes_received as f64 / (1024.0 * 1024.0),
content_length as f64 / (1024.0 * 1024.0),
current_speed,
avg_speed,
eta_secs
);
last_log_time = now;
last_log_bytes = bytes_received;
}
}
// Mark stream as complete
if let Err(e) = writer.complete() {
log::error!("Failed to mark buffer complete: {}", e);
}
let total_time = start_time.elapsed();
let avg_speed = (bytes_received as f64 / total_time.as_secs_f64()) / (1024.0 * 1024.0);
log::info!(
"✅ Streaming download complete: {:.2} MB in {:.1}s ({:.2} MB/s avg)",
bytes_received as f64 / (1024.0 * 1024.0),
total_time.as_secs_f64(),
avg_speed
);
// Cache the complete file for future plays
log::info!("💾 Caching track {} for future playback", track_id);
cache.insert(track_id, all_data);
Ok(())
}
/// Number of Qobuz tracks to prefetch (not total tracks, just Qobuz)
const QOBUZ_PREFETCH_COUNT: usize = 3;
+35 -8
View File
@@ -2,7 +2,7 @@
use tauri::State;
use crate::api::{Album, Artist, ArtistAlbums, SearchResultsPage, Track};
use crate::api::{Album, Artist, ArtistAlbums, LabelDetail, SearchResultsPage, Track};
use crate::api_cache::ApiCacheState;
use crate::AppState;
use serde::{Deserialize, Serialize};
@@ -19,11 +19,12 @@ pub async fn search_albums(
query: String,
limit: Option<u32>,
offset: Option<u32>,
search_type: Option<String>,
state: State<'_, AppState>,
) -> Result<SearchResultsPage<Album>, String> {
let client = state.client.lock().await;
client
.search_albums(&query, limit.unwrap_or(20), offset.unwrap_or(0))
.search_albums(&query, limit.unwrap_or(20), offset.unwrap_or(0), search_type.as_deref())
.await
.map_err(|e| e.to_string())
}
@@ -33,11 +34,12 @@ pub async fn search_tracks(
query: String,
limit: Option<u32>,
offset: Option<u32>,
search_type: Option<String>,
state: State<'_, AppState>,
) -> Result<SearchResultsPage<Track>, String> {
let client = state.client.lock().await;
client
.search_tracks(&query, limit.unwrap_or(20), offset.unwrap_or(0))
.search_tracks(&query, limit.unwrap_or(20), offset.unwrap_or(0), search_type.as_deref())
.await
.map_err(|e| e.to_string())
}
@@ -47,11 +49,12 @@ pub async fn search_artists(
query: String,
limit: Option<u32>,
offset: Option<u32>,
search_type: Option<String>,
state: State<'_, AppState>,
) -> Result<SearchResultsPage<Artist>, String> {
let client = state.client.lock().await;
client
.search_artists(&query, limit.unwrap_or(20), offset.unwrap_or(0))
.search_artists(&query, limit.unwrap_or(20), offset.unwrap_or(0), search_type.as_deref())
.await
.map_err(|e| e.to_string())
}
@@ -59,14 +62,16 @@ pub async fn search_artists(
#[tauri::command]
pub async fn search_all(
query: String,
search_type: Option<String>,
state: State<'_, AppState>,
) -> Result<SearchAllResults, String> {
let client = state.client.lock().await;
let st = search_type.as_deref();
let (albums_result, tracks_result, artists_result) = tokio::join!(
client.search_albums(&query, 30, 0),
client.search_tracks(&query, 8, 0),
client.search_artists(&query, 12, 0)
client.search_albums(&query, 30, 0, st),
client.search_tracks(&query, 8, 0, st),
client.search_artists(&query, 12, 0, st)
);
Ok(SearchAllResults {
@@ -264,3 +269,25 @@ pub async fn get_similar_artists(
.await
.map_err(|e| e.to_string())
}
/// Get label detail with albums
#[tauri::command]
pub async fn get_label(
label_id: u64,
limit: Option<u32>,
offset: Option<u32>,
state: State<'_, AppState>,
) -> Result<LabelDetail, String> {
log::info!(
"Command: get_label {} limit={:?} offset={:?}",
label_id,
limit,
offset
);
let client = state.client.lock().await;
client
.get_label(label_id, limit.unwrap_or(100), offset.unwrap_or(0))
.await
.map_err(|e| e.to_string())
}
+448
View File
@@ -0,0 +1,448 @@
//! Smart Playlist Tauri commands
//!
//! Generates playlists based on MusicBrainz artist relationships
use tauri::State;
use crate::api::QobuzClient;
use crate::musicbrainz::smart_playlists::{
GeneratedPlaylist, PlaylistGenerationConfig, PlaylistRule,
};
use crate::musicbrainz::MusicBrainzSharedState;
use crate::AppState;
/// Preview a smart playlist (get track list without creating)
#[tauri::command]
pub async fn smart_playlist_preview(
rule: PlaylistRule,
config: Option<PlaylistGenerationConfig>,
mb_state: State<'_, MusicBrainzSharedState>,
app_state: State<'_, AppState>,
) -> Result<GeneratedPlaylist, String> {
let config = config.unwrap_or_default();
// Check if MusicBrainz is enabled
if !mb_state.client.is_enabled().await {
return Err("MusicBrainz integration is disabled".to_string());
}
match &rule {
PlaylistRule::BandMembers {
artist_mbid,
include_past_members,
} => {
generate_band_members_playlist(
artist_mbid,
*include_past_members,
&config,
&mb_state,
&app_state,
)
.await
}
PlaylistRule::ArtistGroups { artist_mbid } => {
generate_artist_groups_playlist(artist_mbid, &config, &mb_state, &app_state).await
}
PlaylistRule::CollaboratorNetwork { artist_mbid, depth } => {
generate_collaborator_playlist(artist_mbid, *depth, &config, &mb_state, &app_state)
.await
}
}
}
/// Generate playlist from band members' solo work
async fn generate_band_members_playlist(
artist_mbid: &str,
include_past_members: bool,
config: &PlaylistGenerationConfig,
mb_state: &State<'_, MusicBrainzSharedState>,
app_state: &State<'_, AppState>,
) -> Result<GeneratedPlaylist, String> {
// Get relationships
let relationships = mb_state
.client
.get_artist_with_relations(artist_mbid)
.await?;
// Get the band name for the playlist title
let band_name = relationships.name.clone();
// Collect member names
let mut member_names: Vec<String> = Vec::new();
if let Some(relations) = &relationships.relations {
for relation in relations {
if relation.relation_type == "member of band" {
if let Some(ref artist) = relation.artist {
// Check direction - we want members of this band
if relation.direction.as_deref() != Some("backward") {
// This is a member
let is_past = relation.ended == Some(true);
if !is_past || include_past_members {
member_names.push(artist.name.clone());
}
}
}
}
}
}
if member_names.is_empty() {
return Ok(GeneratedPlaylist {
suggested_name: format!("{} Members", band_name),
description: "No band members found in MusicBrainz".to_string(),
rule: PlaylistRule::BandMembers {
artist_mbid: artist_mbid.to_string(),
include_past_members,
},
included_artists: vec![],
track_ids: vec![],
total_tracks_found: 0,
});
}
// Search Qobuz for each member and get their tracks
let mut all_track_ids: Vec<u64> = Vec::new();
let mut found_artists: Vec<String> = Vec::new();
let client = app_state.client.lock().await;
for member_name in &member_names {
match search_artist_tracks(&client, member_name, config.max_tracks_per_artist).await {
Ok(tracks) => {
if !tracks.is_empty() {
found_artists.push(member_name.clone());
all_track_ids.extend(tracks);
}
}
Err(e) => {
log::warn!("Failed to search tracks for {}: {}", member_name, e);
}
}
// Check if we've hit the limit
if all_track_ids.len() >= config.max_total_tracks {
break;
}
}
let total_found = all_track_ids.len();
all_track_ids.truncate(config.max_total_tracks);
Ok(GeneratedPlaylist {
suggested_name: format!("{} Members", band_name),
description: format!(
"Solo work by members of {}{}",
band_name,
if include_past_members {
" (including past members)"
} else {
""
}
),
rule: PlaylistRule::BandMembers {
artist_mbid: artist_mbid.to_string(),
include_past_members,
},
included_artists: found_artists,
track_ids: all_track_ids,
total_tracks_found: total_found,
})
}
/// Generate playlist from groups an artist is/was a member of
async fn generate_artist_groups_playlist(
artist_mbid: &str,
config: &PlaylistGenerationConfig,
mb_state: &State<'_, MusicBrainzSharedState>,
app_state: &State<'_, AppState>,
) -> Result<GeneratedPlaylist, String> {
// Get relationships
let relationships = mb_state
.client
.get_artist_with_relations(artist_mbid)
.await?;
let artist_name = relationships.name.clone();
// Collect group names
let mut group_names: Vec<String> = Vec::new();
if let Some(relations) = &relationships.relations {
for relation in relations {
if relation.relation_type == "member of band" {
if let Some(ref artist) = relation.artist {
// Check direction - "backward" means this artist is a member OF that group
if relation.direction.as_deref() == Some("backward") {
group_names.push(artist.name.clone());
}
}
}
}
}
if group_names.is_empty() {
return Ok(GeneratedPlaylist {
suggested_name: format!("{}'s Groups", artist_name),
description: "No groups found in MusicBrainz".to_string(),
rule: PlaylistRule::ArtistGroups {
artist_mbid: artist_mbid.to_string(),
},
included_artists: vec![],
track_ids: vec![],
total_tracks_found: 0,
});
}
// Search Qobuz for each group and get their tracks
let mut all_track_ids: Vec<u64> = Vec::new();
let mut found_artists: Vec<String> = Vec::new();
let client = app_state.client.lock().await;
for group_name in &group_names {
match search_artist_tracks(&client, group_name, config.max_tracks_per_artist).await {
Ok(tracks) => {
if !tracks.is_empty() {
found_artists.push(group_name.clone());
all_track_ids.extend(tracks);
}
}
Err(e) => {
log::warn!("Failed to search tracks for {}: {}", group_name, e);
}
}
if all_track_ids.len() >= config.max_total_tracks {
break;
}
}
let total_found = all_track_ids.len();
all_track_ids.truncate(config.max_total_tracks);
Ok(GeneratedPlaylist {
suggested_name: format!("{}'s Groups", artist_name),
description: format!("Tracks from groups {} is/was a member of", artist_name),
rule: PlaylistRule::ArtistGroups {
artist_mbid: artist_mbid.to_string(),
},
included_artists: found_artists,
track_ids: all_track_ids,
total_tracks_found: total_found,
})
}
/// Generate playlist from collaborators
async fn generate_collaborator_playlist(
artist_mbid: &str,
depth: u8,
config: &PlaylistGenerationConfig,
mb_state: &State<'_, MusicBrainzSharedState>,
app_state: &State<'_, AppState>,
) -> Result<GeneratedPlaylist, String> {
// For now, only support depth=1 (direct collaborators)
if depth > 1 {
return Err("Collaborator depth > 1 not yet supported".to_string());
}
// Get relationships
let relationships = mb_state
.client
.get_artist_with_relations(artist_mbid)
.await?;
let artist_name = relationships.name.clone();
// Collect collaborator names
let mut collaborator_names: Vec<String> = Vec::new();
if let Some(relations) = &relationships.relations {
for relation in relations {
if relation.relation_type == "collaboration" {
if let Some(ref artist) = relation.artist {
collaborator_names.push(artist.name.clone());
}
}
}
}
if collaborator_names.is_empty() {
return Ok(GeneratedPlaylist {
suggested_name: format!("{} Collaborations", artist_name),
description: "No collaborators found in MusicBrainz".to_string(),
rule: PlaylistRule::CollaboratorNetwork {
artist_mbid: artist_mbid.to_string(),
depth,
},
included_artists: vec![],
track_ids: vec![],
total_tracks_found: 0,
});
}
// Search Qobuz for each collaborator
let mut all_track_ids: Vec<u64> = Vec::new();
let mut found_artists: Vec<String> = Vec::new();
let client = app_state.client.lock().await;
for collab_name in &collaborator_names {
match search_artist_tracks(&client, collab_name, config.max_tracks_per_artist).await {
Ok(tracks) => {
if !tracks.is_empty() {
found_artists.push(collab_name.clone());
all_track_ids.extend(tracks);
}
}
Err(e) => {
log::warn!("Failed to search tracks for {}: {}", collab_name, e);
}
}
if all_track_ids.len() >= config.max_total_tracks {
break;
}
}
let total_found = all_track_ids.len();
all_track_ids.truncate(config.max_total_tracks);
Ok(GeneratedPlaylist {
suggested_name: format!("{} Collaborations", artist_name),
description: format!("Tracks from artists who have collaborated with {}", artist_name),
rule: PlaylistRule::CollaboratorNetwork {
artist_mbid: artist_mbid.to_string(),
depth,
},
included_artists: found_artists,
track_ids: all_track_ids,
total_tracks_found: total_found,
})
}
/// Search for an artist on Qobuz and get their top tracks
async fn search_artist_tracks(
client: &QobuzClient,
artist_name: &str,
max_tracks: usize,
) -> Result<Vec<u64>, String> {
// Search for the artist
let search_results = client
.search_artists(artist_name, 5, 0, None)
.await
.map_err(|e| format!("Search failed: {}", e))?;
if search_results.items.is_empty() {
return Ok(vec![]);
}
// Find exact or close match
let artist = search_results
.items
.iter()
.find(|a| a.name.to_lowercase() == artist_name.to_lowercase())
.or_else(|| search_results.items.first());
let Some(artist) = artist else {
return Ok(vec![]);
};
let artist_id = artist.id;
// Get the artist's top tracks by searching with artist name
let track_results = client
.search_tracks(artist_name, max_tracks as u32, 0, None)
.await
.map_err(|e| format!("Track search failed: {}", e))?;
// Filter tracks by this specific artist and extract IDs
let track_ids: Vec<u64> = track_results
.items
.iter()
.filter(|t| {
// Check if performer matches
t.performer
.as_ref()
.map(|p| p.id == artist_id)
.unwrap_or(false)
})
.map(|t| t.id)
.take(max_tracks)
.collect();
Ok(track_ids)
}
/// Get the artist MBID for a given artist name (helper for frontend)
#[tauri::command]
pub async fn smart_playlist_resolve_artist(
artist_name: String,
mb_state: State<'_, MusicBrainzSharedState>,
) -> Result<Option<String>, String> {
if !mb_state.client.is_enabled().await {
return Err("MusicBrainz integration is disabled".to_string());
}
let result = mb_state.client.search_artist(&artist_name).await?;
// Find best match with high confidence
let best_match = result
.artists
.iter()
.find(|a| a.score.unwrap_or(0) >= 90);
Ok(best_match.map(|a| a.id.clone()))
}
/// Get available playlist types for an artist
#[tauri::command]
pub async fn smart_playlist_get_available_types(
artist_mbid: String,
mb_state: State<'_, MusicBrainzSharedState>,
) -> Result<Vec<String>, String> {
if !mb_state.client.is_enabled().await {
return Err("MusicBrainz integration is disabled".to_string());
}
let relationships = mb_state
.client
.get_artist_with_relations(&artist_mbid)
.await?;
let mut available: Vec<String> = Vec::new();
if let Some(relations) = &relationships.relations {
let mut has_members = false;
let mut has_groups = false;
let mut has_collaborators = false;
for relation in relations {
match relation.relation_type.as_str() {
"member of band" => {
if relation.direction.as_deref() == Some("backward") {
has_groups = true;
} else {
has_members = true;
}
}
"collaboration" => {
has_collaborators = true;
}
_ => {}
}
}
if has_members {
available.push("band_members".to_string());
}
if has_groups {
available.push("artist_groups".to_string());
}
if has_collaborators {
available.push("collaborator_network".to_string());
}
}
Ok(available)
}
+54 -2
View File
@@ -16,6 +16,10 @@ pub struct AudioSettings {
pub backend_type: Option<AudioBackendType>, // None = auto-detect
pub alsa_plugin: Option<AlsaPlugin>, // Only used when backend is ALSA
pub alsa_hardware_volume: bool, // Use ALSA mixer for volume (only with hw: devices)
/// When true, uncached tracks start playing via streaming instead of waiting for full download
pub stream_first_track: bool,
/// Initial buffer size in seconds before starting streaming playback (1-10, default 3)
pub stream_buffer_seconds: u8,
}
impl Default for AudioSettings {
@@ -28,6 +32,8 @@ impl Default for AudioSettings {
backend_type: None, // Auto-detect (PipeWire if available, else ALSA)
alsa_plugin: Some(AlsaPlugin::Hw), // Default to hw (bit-perfect)
alsa_hardware_volume: false, // Disabled by default (maximum compatibility)
stream_first_track: false, // Disabled by default (current behavior)
stream_buffer_seconds: 3, // 3 seconds initial buffer
}
}
}
@@ -58,7 +64,9 @@ impl AudioSettingsStore {
preferred_sample_rate INTEGER,
backend_type TEXT,
alsa_plugin TEXT,
alsa_hardware_volume INTEGER NOT NULL DEFAULT 0
alsa_hardware_volume INTEGER NOT NULL DEFAULT 0,
stream_first_track INTEGER NOT NULL DEFAULT 0,
stream_buffer_seconds INTEGER NOT NULL DEFAULT 3
);
INSERT OR IGNORE INTO audio_settings (id, exclusive_mode, dac_passthrough)
VALUES (1, 0, 0);"
@@ -68,6 +76,8 @@ impl AudioSettingsStore {
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN backend_type TEXT", []);
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN alsa_plugin TEXT", []);
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN alsa_hardware_volume INTEGER DEFAULT 0", []);
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN stream_first_track INTEGER DEFAULT 0", []);
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN stream_buffer_seconds INTEGER DEFAULT 3", []);
Ok(Self { conn })
}
@@ -75,7 +85,7 @@ impl AudioSettingsStore {
pub fn get_settings(&self) -> Result<AudioSettings, String> {
self.conn
.query_row(
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume FROM audio_settings WHERE id = 1",
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds FROM audio_settings WHERE id = 1",
[],
|row| {
// Parse backend_type from JSON string
@@ -96,6 +106,8 @@ impl AudioSettingsStore {
backend_type,
alsa_plugin,
alsa_hardware_volume: row.get::<_, Option<i64>>(6)?.unwrap_or(0) != 0,
stream_first_track: row.get::<_, Option<i64>>(7)?.unwrap_or(0) != 0,
stream_buffer_seconds: row.get::<_, Option<i64>>(8)?.unwrap_or(3) as u8,
})
},
)
@@ -181,6 +193,28 @@ impl AudioSettingsStore {
.map_err(|e| format!("Failed to set ALSA hardware volume: {}", e))?;
Ok(())
}
pub fn set_stream_first_track(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET stream_first_track = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set stream first track: {}", e))?;
Ok(())
}
pub fn set_stream_buffer_seconds(&self, seconds: u8) -> Result<(), String> {
// Clamp to valid range 1-10
let clamped = seconds.clamp(1, 10);
self.conn
.execute(
"UPDATE audio_settings SET stream_buffer_seconds = ?1 WHERE id = 1",
params![clamped as i64],
)
.map_err(|e| format!("Failed to set stream buffer seconds: {}", e))?;
Ok(())
}
}
/// Thread-safe wrapper
@@ -267,3 +301,21 @@ pub fn set_audio_alsa_hardware_volume(
let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
store.set_alsa_hardware_volume(enabled)
}
#[tauri::command]
pub fn set_audio_stream_first_track(
state: tauri::State<'_, AudioSettingsState>,
enabled: bool,
) -> Result<(), String> {
let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
store.set_stream_first_track(enabled)
}
#[tauri::command]
pub fn set_audio_stream_buffer_seconds(
state: tauri::State<'_, AudioSettingsState>,
seconds: u8,
) -> Result<(), String> {
let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
store.set_stream_buffer_seconds(seconds)
}
+7
View File
@@ -436,6 +436,7 @@ pub fn run() {
commands::get_artist_detail,
commands::get_artist_albums,
commands::get_similar_artists,
commands::get_label,
// Playback commands
commands::play_track,
commands::prefetch_track,
@@ -698,6 +699,8 @@ pub fn run() {
config::audio_settings::set_audio_backend_type,
config::audio_settings::set_audio_alsa_plugin,
config::audio_settings::set_audio_alsa_hardware_volume,
config::audio_settings::set_audio_stream_first_track,
config::audio_settings::set_audio_stream_buffer_seconds,
// Audio backend commands
commands::get_available_backends,
commands::get_devices_for_backend,
@@ -775,6 +778,10 @@ pub fn run() {
commands::listenbrainz_flush_queue,
commands::listenbrainz_clear_queue,
commands::listenbrainz_cleanup_queue,
// Smart playlist generation commands
commands::smart_playlist_preview,
commands::smart_playlist_resolve_artist,
commands::smart_playlist_get_available_types,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+1
View File
@@ -17,6 +17,7 @@
pub mod cache;
pub mod client;
pub mod models;
pub mod smart_playlists;
pub use cache::{CacheStats, MusicBrainzCache, MusicBrainzCacheState};
pub use client::{MusicBrainzClient, MusicBrainzConfig};
@@ -0,0 +1,79 @@
//! Smart Playlist Generation
//!
//! Generates playlists based on MusicBrainz artist relationships.
//! Uses Stage 1 infrastructure to resolve artists and Stage 3 relationships.
use serde::{Deserialize, Serialize};
/// Types of smart playlists that can be generated
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlaylistRule {
/// All solo work by members of a band
BandMembers {
/// The band's MusicBrainz ID
artist_mbid: String,
/// Include past members
include_past_members: bool,
},
/// Groups that a solo artist is/was a member of
ArtistGroups {
/// The artist's MusicBrainz ID
artist_mbid: String,
},
/// Artists who have collaborated with the seed artist
CollaboratorNetwork {
/// Seed artist's MusicBrainz ID
artist_mbid: String,
/// How many degrees of separation (1 = direct collaborators only)
depth: u8,
},
}
/// A generated smart playlist
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GeneratedPlaylist {
/// Suggested name for the playlist
pub suggested_name: String,
/// Description of how it was generated
pub description: String,
/// The rule used to generate this playlist
pub rule: PlaylistRule,
/// Artist names included in this playlist
pub included_artists: Vec<String>,
/// Qobuz track IDs found
pub track_ids: Vec<u64>,
/// Total tracks found (before any limits)
pub total_tracks_found: usize,
}
/// Result of searching for an artist's tracks on Qobuz
#[derive(Debug, Clone)]
pub struct ArtistTracksResult {
pub artist_name: String,
pub qobuz_artist_id: Option<u64>,
pub track_ids: Vec<u64>,
}
/// Configuration for playlist generation
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistGenerationConfig {
/// Maximum tracks per related artist
pub max_tracks_per_artist: usize,
/// Maximum total tracks in playlist
pub max_total_tracks: usize,
/// Only include tracks available in user's region
pub filter_unavailable: bool,
}
impl Default for PlaylistGenerationConfig {
fn default() -> Self {
Self {
max_tracks_per_artist: 10,
max_total_tracks: 100,
filter_unavailable: true,
}
}
}
+309
View File
@@ -11,6 +11,9 @@
//! Supports both rodio (PipeWire/Pulse) and direct ALSA (hw: devices).
mod playback_engine;
mod streaming_source;
pub use streaming_source::{BufferedMediaSource, BufferWriter, StreamingConfig, IncrementalStreamingSource};
use std::io::{BufReader, Cursor, Read, Seek, SeekFrom};
use std::panic::{self, AssertUnwindSafe};
@@ -48,6 +51,15 @@ enum AudioCommand {
sample_rate: u32,
channels: u16,
},
/// Play from streaming source (BufferedMediaSource)
/// The download task should already be running and pushing to the source
PlayStreaming {
source: Arc<BufferedMediaSource>,
track_id: u64,
sample_rate: u32,
channels: u16,
duration_secs: u64,
},
/// Pause playback
Pause,
/// Resume playback
@@ -1046,6 +1058,216 @@ impl Player {
actual_duration
);
}
AudioCommand::PlayStreaming { source, track_id, sample_rate, channels, duration_secs } => {
log::info!(
"Audio thread: starting streaming playback for track {} ({}Hz, {} channels, {}s)",
track_id,
sample_rate,
channels,
duration_secs
);
*pause_suspend_deadline = None;
// Get DAC passthrough setting
let dac_passthrough = thread_settings
.lock()
.ok()
.map(|s| s.dac_passthrough)
.unwrap_or(false);
// Check if we need to recreate the stream
let format_changed = *current_sample_rate != Some(sample_rate)
|| *current_channels != Some(channels);
let using_alsa_direct = thread_settings
.lock()
.ok()
.and_then(|s| s.backend_type)
.map(|b| b == AudioBackendType::Alsa)
.unwrap_or(false);
let needs_new_stream = stream_opt.is_none()
|| (dac_passthrough && format_changed)
|| (using_alsa_direct && format_changed);
if needs_new_stream {
if stream_opt.is_some() {
if (dac_passthrough || using_alsa_direct) && format_changed {
let mode = if using_alsa_direct { "ALSA Direct" } else { "DAC passthrough" };
log::info!(
"Streaming: Sample rate/channels changed to {}Hz/{}ch - recreating OutputStream ({})",
sample_rate,
channels,
mode
);
}
drop(stream_opt.take());
}
let stream_result = if let Some(settings) = thread_settings.lock().ok() {
match try_init_stream_with_backend(&settings, sample_rate, channels) {
Some(result) => result,
None => {
log::info!("Backend system not configured, using legacy CPAL path");
let device = if let Some(ref name) = *current_device_name {
host.output_devices()
.ok()
.and_then(|mut devices| {
devices.find(|d| d.name().ok().as_ref() == Some(name))
})
.or_else(|| host.default_output_device())
} else {
host.default_output_device()
};
let Some(device) = device else {
log::error!("No audio output device available for streaming");
thread_state.set_stream_error(true);
return;
};
if let Ok(name) = device.name() {
thread_state.set_current_device(Some(name));
}
create_output_stream_with_config(
&device,
sample_rate,
channels,
dac_passthrough,
).map(|(stream, handle)| StreamType::Rodio(stream, handle))
}
}
} else {
let device = host.default_output_device();
let Some(device) = device else {
log::error!("No audio output device available for streaming");
thread_state.set_stream_error(true);
return;
};
create_output_stream_with_config(&device, sample_rate, channels, dac_passthrough)
.map(|(stream, handle)| StreamType::Rodio(stream, handle))
};
match stream_result {
Ok(stream) => {
*stream_opt = Some(stream);
*current_sample_rate = Some(sample_rate);
*current_channels = Some(channels);
thread_state.set_stream_error(false);
log::info!("✅ Streaming audio stream ready at {}Hz", sample_rate);
std::thread::sleep(Duration::from_millis(150));
}
Err(e) => {
log::error!("❌ Failed to create stream for streaming at {}Hz: {}", sample_rate, e);
thread_state.set_stream_error(true);
return;
}
}
}
let Some(ref stream) = *stream_opt else {
log::error!("Audio thread: no audio device available for streaming");
return;
};
// Stop previous engine
if let Some(engine) = current_engine.take() {
engine.stop();
std::thread::sleep(Duration::from_millis(50));
}
// Create PlaybackEngine
let mut engine = match stream {
StreamType::Rodio(_stream, handle) => {
match PlaybackEngine::new_rodio(handle) {
Ok(e) => {
*consecutive_sink_failures = 0;
thread_state.set_stream_error(false);
e
}
Err(e) => {
log::error!("Failed to create engine for streaming: {}", e);
return;
}
}
}
#[cfg(target_os = "linux")]
StreamType::AlsaDirect(alsa_stream) => {
let hardware_volume = thread_settings
.lock()
.ok()
.map(|s| s.alsa_hardware_volume)
.unwrap_or(false);
PlaybackEngine::new_alsa_direct(alsa_stream.clone(), hardware_volume)
}
};
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
engine.set_volume(volume);
// Wait for minimum buffer before starting playback
log::info!("Streaming: waiting for initial buffer...");
let start_wait = Instant::now();
let max_wait = Duration::from_secs(30);
while !source.has_min_buffer() && start_wait.elapsed() < max_wait {
std::thread::sleep(Duration::from_millis(50));
}
if !source.has_min_buffer() {
log::error!("Streaming: timeout waiting for initial buffer");
return;
}
let buffer_wait_ms = start_wait.elapsed().as_millis();
log::info!(
"Streaming: initial buffer ready in {}ms, creating incremental decoder...",
buffer_wait_ms
);
// Create incremental streaming source - this starts playback IMMEDIATELY
// while continuing to decode/download in background
let incremental_source = match IncrementalStreamingSource::new(source.clone()) {
Ok(s) => s,
Err(e) => {
log::error!("Failed to create incremental streaming source: {}", e);
return;
}
};
// Verify sample rate/channels match what we expected
let actual_sr = incremental_source.get_sample_rate();
let actual_ch = incremental_source.get_channels();
if actual_sr != sample_rate || actual_ch != channels {
log::warn!(
"Streaming: detected format {}Hz/{}ch differs from expected {}Hz/{}ch",
actual_sr, actual_ch, sample_rate, channels
);
}
// Set duration from track metadata (passed from frontend)
// This allows the seekbar to show progress even during streaming
thread_state.duration.store(duration_secs, Ordering::SeqCst);
// Box the incremental source to match the expected type
let source_to_play: Box<dyn Source<Item = i16> + Send> = Box::new(incremental_source);
if let Err(e) = engine.append(source_to_play) {
log::error!("Failed to append streaming source to engine: {}", e);
return;
}
thread_state.is_playing.store(true, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst);
thread_state.current_track_id.store(track_id, Ordering::SeqCst);
thread_state.start_playback_timer(0);
*current_engine = Some(engine);
log::info!(
"Audio thread: streaming playback STARTED in {}ms (incremental decode active)",
start_wait.elapsed().as_millis()
);
}
AudioCommand::Pause => {
if let Some(ref engine) = *current_engine {
engine.pause();
@@ -1438,6 +1660,93 @@ impl Player {
Ok(())
}
/// Play from streaming source (starts playback before full download)
/// Returns the BufferWriter so caller can push data as it downloads
pub fn play_streaming(
&self,
track_id: u64,
sample_rate: u32,
channels: u16,
content_length: u64,
buffer_seconds: u8,
duration_secs: u64,
) -> Result<BufferWriter, String> {
log::info!(
"Player: Starting streaming playback for track {} ({}Hz, {}ch, {} bytes total, {}s)",
track_id,
sample_rate,
channels,
content_length,
duration_secs
);
// Use StreamingConfig::from_seconds for proper buffer sizing
let config = StreamingConfig::from_seconds(buffer_seconds);
let (source, writer) = BufferedMediaSource::new(config, Some(content_length));
let source = Arc::new(source);
self.tx
.send(AudioCommand::PlayStreaming {
source: source.clone(),
track_id,
sample_rate,
channels,
duration_secs,
})
.map_err(|e| {
log::error!("Player: Failed to send streaming command: {}", e);
format!("Failed to send streaming play command: {}", e)
})?;
log::info!("Player: Streaming playback initiated");
Ok(writer)
}
/// Play from streaming source with dynamic buffer based on measured speed
/// Returns the BufferWriter so caller can push data as it downloads
pub fn play_streaming_dynamic(
&self,
track_id: u64,
sample_rate: u32,
channels: u16,
content_length: u64,
speed_mbps: f64,
duration_secs: u64,
) -> Result<BufferWriter, String> {
log::info!(
"Player: Starting dynamic streaming for track {} ({}Hz, {}ch, {:.2} MB, {:.1} MB/s, {}s)",
track_id,
sample_rate,
channels,
content_length as f64 / (1024.0 * 1024.0),
speed_mbps,
duration_secs
);
// Use StreamingConfig::from_speed_mbps for dynamic buffer sizing
let config = StreamingConfig::from_speed_mbps(speed_mbps);
let (source, writer) = BufferedMediaSource::new(config, Some(content_length));
let source = Arc::new(source);
self.tx
.send(AudioCommand::PlayStreaming {
source: source.clone(),
track_id,
sample_rate,
channels,
duration_secs,
})
.map_err(|e| {
log::error!("Player: Failed to send streaming command: {}", e);
format!("Failed to send streaming play command: {}", e)
})?;
log::info!("Player: Dynamic streaming playback initiated");
Ok(writer)
}
/// Download audio from URL with timeout
async fn download_audio(&self, url: &str) -> Result<Vec<u8>, String> {
use std::time::Duration;
+31 -7
View File
@@ -23,6 +23,7 @@ pub enum PlaybackEngine {
AlsaDirect {
stream: Arc<AlsaDirectStream>,
is_playing: Arc<AtomicBool>,
should_stop: Arc<AtomicBool>, // Separate flag for full stop vs pause
position_frames: Arc<AtomicU64>,
duration_frames: Arc<AtomicU64>,
playback_thread: Option<thread::JoinHandle<()>>,
@@ -44,6 +45,7 @@ impl PlaybackEngine {
Self::AlsaDirect {
stream,
is_playing: Arc::new(AtomicBool::new(false)),
should_stop: Arc::new(AtomicBool::new(false)),
position_frames: Arc::new(AtomicU64::new(0)),
duration_frames: Arc::new(AtomicU64::new(0)),
playback_thread: None,
@@ -64,6 +66,7 @@ impl PlaybackEngine {
Self::AlsaDirect {
stream,
is_playing,
should_stop,
position_frames,
duration_frames,
playback_thread,
@@ -74,15 +77,18 @@ impl PlaybackEngine {
// 2. Converts i16 samples to f32
// 3. Writes to ALSA PCM
// 4. Tracks position
// 5. Supports pause/resume without terminating
let stream_clone = stream.clone();
let is_playing_clone = is_playing.clone();
let should_stop_clone = should_stop.clone();
let position_clone = position_frames.clone();
let duration_clone = duration_frames.clone();
let channels = stream.channels();
is_playing.store(true, Ordering::SeqCst);
should_stop.store(false, Ordering::SeqCst);
position_clone.store(0, Ordering::SeqCst);
log::info!("[ALSA Direct Engine] Starting streaming playback thread");
@@ -98,11 +104,22 @@ impl PlaybackEngine {
let mut source_iter = source.into_iter();
let mut natural_end = false;
loop {
// Check if we should stop
if !is_playing_clone.load(Ordering::SeqCst) {
log::info!("[ALSA Direct Engine] Playback paused/stopped");
break;
'playback: loop {
// Check if we should stop completely (not just pause)
if should_stop_clone.load(Ordering::SeqCst) {
log::info!("[ALSA Direct Engine] Stop requested, terminating thread");
break 'playback;
}
// Check if paused - wait instead of terminating
while !is_playing_clone.load(Ordering::SeqCst) {
// Still check for stop while paused
if should_stop_clone.load(Ordering::SeqCst) {
log::info!("[ALSA Direct Engine] Stop requested while paused");
break 'playback;
}
// Sleep briefly to avoid busy-waiting
std::thread::sleep(Duration::from_millis(50));
}
// Fill buffer from source
@@ -118,13 +135,14 @@ impl PlaybackEngine {
// End of stream
log::info!("[ALSA Direct Engine] Stream ended (total frames: {})", total_frames);
natural_end = true;
break;
break 'playback;
}
// Write to ALSA (auto-converts based on detected format)
// This is bit-perfect: no resampling, no mixing, direct to hardware
if let Err(e) = stream_clone.write(&buffer_i16) {
log::error!("[ALSA Direct Engine] Write failed: {}", e);
break;
break 'playback;
}
// Update position
@@ -160,6 +178,7 @@ impl PlaybackEngine {
match self {
Self::Rodio { sink } => sink.play(),
Self::AlsaDirect { is_playing, .. } => {
log::info!("[ALSA Direct Engine] Resume requested");
is_playing.store(true, Ordering::SeqCst);
}
}
@@ -170,6 +189,7 @@ impl PlaybackEngine {
match self {
Self::Rodio { sink } => sink.pause(),
Self::AlsaDirect { is_playing, .. } => {
log::info!("[ALSA Direct Engine] Pause requested");
is_playing.store(false, Ordering::SeqCst);
}
}
@@ -184,9 +204,13 @@ impl PlaybackEngine {
Self::AlsaDirect {
stream,
is_playing,
should_stop,
playback_thread,
..
} => {
log::info!("[ALSA Direct Engine] Stop requested");
// Signal thread to stop completely
should_stop.store(true, Ordering::SeqCst);
is_playing.store(false, Ordering::SeqCst);
// Wait for playback thread to finish
+771
View File
@@ -0,0 +1,771 @@
//! Buffered media source for streaming playback.
//!
//! Provides two main components:
//! 1. `BufferedMediaSource` - Wraps an async HTTP response to provide a synchronous
//! `Read + Seek` interface required by symphonia decoders.
//! 2. `IncrementalStreamingSource` - A rodio Source that decodes audio packets
//! incrementally as they become available, allowing playback to start before
//! the entire file is downloaded.
//!
//! # Design
//!
//! The source uses a growing buffer that accumulates data from the HTTP response.
//! - Reads block if requesting data not yet buffered
//! - Seek forward blocks until data is available
//! - Seek backward works within buffered data
//! - Seek beyond current buffer position blocks until data arrives
//!
//! # Thread Safety
//!
//! The buffer state is shared between:
//! - The reader (audio thread, synchronous)
//! - The writer (download task, async)
//!
//! Communication uses `Mutex` + `Condvar` for blocking synchronization.
use std::collections::VecDeque;
use std::io::{Error as IoError, ErrorKind, Read, Result as IoResult, Seek, SeekFrom};
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
use rodio::Source;
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{Decoder, DecoderOptions};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::{FormatOptions, FormatReader};
use symphonia::core::io::{MediaSource, MediaSourceStream};
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use symphonia::default::{get_codecs, get_probe};
/// Configuration for the streaming buffer
#[derive(Debug, Clone)]
pub struct StreamingConfig {
/// Minimum bytes to buffer before allowing reads (for format detection)
pub initial_buffer_bytes: usize,
/// Maximum buffer size before backpressure (not enforced, just for info)
pub max_buffer_bytes: usize,
}
impl Default for StreamingConfig {
fn default() -> Self {
Self {
// 512KB default - enough for format headers and ~2-5 seconds of audio
// This allows playback to start quickly while still having enough
// buffer to handle network jitter
initial_buffer_bytes: 512 * 1024,
// 100MB max buffer
max_buffer_bytes: 100 * 1024 * 1024,
}
}
}
impl StreamingConfig {
/// Create config from buffer seconds and approximate bitrate
///
/// For Hi-Res FLAC at 192kHz/24bit stereo, bitrate is roughly 9.2 Mbps
/// We estimate ~1MB per second as a conservative approximation
pub fn from_seconds(seconds: u8) -> Self {
// Minimum 256KB to ensure format detection works
let bytes = ((seconds as usize) * 1024 * 1024).max(256 * 1024);
Self {
initial_buffer_bytes: bytes,
max_buffer_bytes: 100 * 1024 * 1024,
}
}
/// Create a minimal config for fastest startup
/// Uses smallest buffer that still allows format detection (~256KB)
pub fn fast_start() -> Self {
Self {
initial_buffer_bytes: 256 * 1024,
max_buffer_bytes: 100 * 1024 * 1024,
}
}
/// Create config dynamically based on measured download speed
///
/// - Very fast (>10 MB/s): 256KB (instant start)
/// - Fast (5-10 MB/s): 384KB
/// - Normal (2-5 MB/s): 512KB
/// - Slow (1-2 MB/s): 1MB (more buffer to prevent stutter)
/// - Very slow (<1 MB/s): 2MB
pub fn from_speed_mbps(speed_mbps: f64) -> Self {
let initial_buffer = if speed_mbps >= 10.0 {
256 * 1024 // 256KB - instant start for very fast connections
} else if speed_mbps >= 5.0 {
384 * 1024 // 384KB
} else if speed_mbps >= 2.0 {
512 * 1024 // 512KB - default
} else if speed_mbps >= 1.0 {
1024 * 1024 // 1MB - more buffer for slower connections
} else {
2 * 1024 * 1024 // 2MB - maximum buffer for very slow connections
};
log::info!(
"📶 Dynamic buffer: {:.1} MB/s detected → {}KB initial buffer",
speed_mbps,
initial_buffer / 1024
);
Self {
initial_buffer_bytes: initial_buffer,
max_buffer_bytes: 100 * 1024 * 1024,
}
}
}
/// Internal state shared between reader and writer
struct BufferState {
/// Accumulated data from HTTP response
data: Vec<u8>,
/// True when HTTP download is complete
download_complete: bool,
/// Error from download, if any
download_error: Option<String>,
/// Total expected size (from Content-Length), if known
total_size: Option<u64>,
}
/// A media source that buffers from an async HTTP stream.
///
/// Provides `Read + Seek` interface for decoders while data is still downloading.
/// The source is created with a `BufferWriter` that receives chunks from the
/// download task.
pub struct BufferedMediaSource {
state: Arc<(Mutex<BufferState>, Condvar)>,
config: StreamingConfig,
/// Each reader has its own read position
read_pos: std::sync::atomic::AtomicU64,
}
impl BufferedMediaSource {
/// Create a new buffered source.
///
/// Returns the source and a writer for pushing downloaded chunks.
/// The writer should be used from the async download task.
pub fn new(config: StreamingConfig, total_size: Option<u64>) -> (Self, BufferWriter) {
let state = Arc::new((
Mutex::new(BufferState {
data: Vec::with_capacity(config.initial_buffer_bytes),
download_complete: false,
download_error: None,
total_size,
}),
Condvar::new(),
));
let source = Self {
state: Arc::clone(&state),
config: config.clone(),
read_pos: std::sync::atomic::AtomicU64::new(0),
};
let writer = BufferWriter { state };
(source, writer)
}
/// Create a new reader that shares the same buffer but has its own read position.
/// This is used to pass to symphonia which needs ownership of the reader.
pub fn create_reader(&self) -> Self {
Self {
state: Arc::clone(&self.state),
config: self.config.clone(),
read_pos: std::sync::atomic::AtomicU64::new(0),
}
}
/// Wait until initial buffer is filled or download completes.
///
/// This should be called before passing the source to the decoder,
/// to ensure enough data is available for format detection.
///
/// Returns error if download fails before initial buffer is filled.
pub fn wait_for_initial_buffer(&self) -> IoResult<()> {
let (lock, cvar) = &*self.state;
let mut state = lock.lock().map_err(|_| {
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock")
})?;
while state.data.len() < self.config.initial_buffer_bytes
&& !state.download_complete
&& state.download_error.is_none()
{
state = cvar.wait(state).map_err(|_| {
IoError::new(ErrorKind::Other, "Condition variable wait failed")
})?;
}
if let Some(ref err) = state.download_error {
return Err(IoError::new(ErrorKind::Other, err.clone()));
}
Ok(())
}
/// Check if download is complete (full file in buffer)
pub fn is_complete(&self) -> bool {
let (lock, _) = &*self.state;
if let Ok(state) = lock.lock() {
state.download_complete && state.download_error.is_none()
} else {
false
}
}
/// Get current buffer size in bytes
pub fn buffer_size(&self) -> usize {
let (lock, _) = &*self.state;
if let Ok(state) = lock.lock() {
state.data.len()
} else {
0
}
}
/// Get the complete data if download finished successfully.
///
/// Used to store in cache after streaming playback completes.
/// Returns None if download is not complete or failed.
pub fn take_complete_data(&self) -> Option<Vec<u8>> {
let (lock, _) = &*self.state;
if let Ok(state) = lock.lock() {
if state.download_complete && state.download_error.is_none() {
Some(state.data.clone())
} else {
None
}
} else {
None
}
}
/// Get download progress as a fraction (0.0 to 1.0)
///
/// Returns None if total size is unknown
pub fn progress(&self) -> Option<f32> {
let (lock, _) = &*self.state;
if let Ok(state) = lock.lock() {
state.total_size.map(|total| {
if total == 0 {
1.0
} else {
state.data.len() as f32 / total as f32
}
})
} else {
None
}
}
/// Check if minimum buffer for playback is available
///
/// Returns true when initial_buffer_bytes have been buffered
/// or the download is complete.
pub fn has_min_buffer(&self) -> bool {
let (lock, _) = &*self.state;
if let Ok(state) = lock.lock() {
state.data.len() >= self.config.initial_buffer_bytes || state.download_complete
} else {
false
}
}
}
impl Read for BufferedMediaSource {
fn read(&mut self, buf: &mut [u8]) -> IoResult<usize> {
use std::sync::atomic::Ordering;
let (lock, cvar) = &*self.state;
let mut state = lock.lock().map_err(|_| {
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock")
})?;
let read_pos = self.read_pos.load(Ordering::SeqCst) as usize;
// Wait for data if we're ahead of buffer
while read_pos >= state.data.len()
&& !state.download_complete
&& state.download_error.is_none()
{
state = cvar.wait(state).map_err(|_| {
IoError::new(ErrorKind::Other, "Condition variable wait failed")
})?;
}
// Check for errors
if let Some(ref err) = state.download_error {
return Err(IoError::new(ErrorKind::Other, err.clone()));
}
// EOF if at end and download complete
if read_pos >= state.data.len() && state.download_complete {
return Ok(0);
}
// Read available data
let available = state.data.len() - read_pos;
let to_read = buf.len().min(available);
buf[..to_read].copy_from_slice(&state.data[read_pos..read_pos + to_read]);
self.read_pos.store((read_pos + to_read) as u64, Ordering::SeqCst);
Ok(to_read)
}
}
impl Seek for BufferedMediaSource {
fn seek(&mut self, pos: SeekFrom) -> IoResult<u64> {
use std::sync::atomic::Ordering;
let (lock, cvar) = &*self.state;
let mut state = lock.lock().map_err(|_| {
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock")
})?;
let current_pos = self.read_pos.load(Ordering::SeqCst) as i64;
let new_pos = match pos {
SeekFrom::Start(offset) => offset as i64,
SeekFrom::Current(offset) => current_pos + offset,
SeekFrom::End(offset) => {
// For End seeks, we need to know total size or have complete download
if let Some(total) = state.total_size {
total as i64 + offset
} else if state.download_complete {
state.data.len() as i64 + offset
} else {
// Can't seek from end without knowing size
return Err(IoError::new(
ErrorKind::Unsupported,
"Cannot seek from end while streaming without known size",
));
}
}
};
if new_pos < 0 {
return Err(IoError::new(
ErrorKind::InvalidInput,
"Seek position before start of stream",
));
}
let new_pos_usize = new_pos as usize;
// If seeking forward beyond buffer, wait for data
while new_pos_usize > state.data.len()
&& !state.download_complete
&& state.download_error.is_none()
{
state = cvar.wait(state).map_err(|_| {
IoError::new(ErrorKind::Other, "Condition variable wait failed")
})?;
}
if let Some(ref err) = state.download_error {
return Err(IoError::new(ErrorKind::Other, err.clone()));
}
// After download complete, check bounds
if state.download_complete && new_pos_usize > state.data.len() {
return Err(IoError::new(
ErrorKind::InvalidInput,
"Seek position beyond end of stream",
));
}
self.read_pos.store(new_pos as u64, Ordering::SeqCst);
Ok(new_pos as u64)
}
}
// Required for symphonia MediaSource trait
impl MediaSource for BufferedMediaSource {
fn is_seekable(&self) -> bool {
// We support seeking within buffered data
true
}
fn byte_len(&self) -> Option<u64> {
let (lock, _) = &*self.state;
if let Ok(state) = lock.lock() {
state.total_size
} else {
None
}
}
}
/// Writer half for pushing downloaded chunks from the async download task.
///
/// This is the sender side that receives data from the HTTP response
/// and makes it available to the `BufferedMediaSource` reader.
#[derive(Clone)]
pub struct BufferWriter {
state: Arc<(Mutex<BufferState>, Condvar)>,
}
impl BufferWriter {
/// Push a chunk of downloaded data
///
/// This wakes up any readers waiting for data.
pub fn push_chunk(&self, chunk: &[u8]) -> Result<(), String> {
let (lock, cvar) = &*self.state;
let mut state = lock.lock().map_err(|_| "Failed to acquire buffer lock")?;
state.data.extend_from_slice(chunk);
cvar.notify_all();
Ok(())
}
/// Mark download as complete
///
/// After this is called, readers will receive EOF after reading all buffered data.
pub fn complete(&self) -> Result<(), String> {
let (lock, cvar) = &*self.state;
let mut state = lock.lock().map_err(|_| "Failed to acquire buffer lock")?;
state.download_complete = true;
cvar.notify_all();
Ok(())
}
/// Mark download as failed
///
/// After this is called, readers will receive the error on next read.
pub fn error(&self, err: String) -> Result<(), String> {
let (lock, cvar) = &*self.state;
let mut state = lock.lock().map_err(|_| "Failed to acquire buffer lock")?;
state.download_error = Some(err);
cvar.notify_all();
Ok(())
}
/// Get current buffer size in bytes
pub fn buffer_size(&self) -> usize {
let (lock, _) = &*self.state;
if let Ok(state) = lock.lock() {
state.data.len()
} else {
0
}
}
}
// =============================================================================
// IncrementalStreamingSource - A rodio Source that decodes on-demand
// =============================================================================
/// A rodio Source that decodes audio packets incrementally from a BufferedMediaSource.
///
/// This allows playback to start immediately after the initial buffer is filled,
/// while the rest of the file continues downloading in the background.
///
/// The source maintains an internal queue of decoded samples and decodes more
/// packets on-demand as samples are consumed.
pub struct IncrementalStreamingSource {
/// Sample rate of the audio
sample_rate: u32,
/// Number of channels
channels: u16,
/// Queue of decoded samples ready to play
sample_queue: VecDeque<i16>,
/// The format reader (demuxer)
format: Box<dyn FormatReader>,
/// The audio decoder
decoder: Box<dyn Decoder>,
/// Track ID we're decoding
track_id: u32,
/// Whether we've reached end of stream
finished: bool,
/// Number of packets decoded (for stats)
packets_decoded: u64,
/// Reference to the buffered source (for cache retrieval after playback)
buffered_source: Arc<BufferedMediaSource>,
}
impl IncrementalStreamingSource {
/// Create a new incremental streaming source.
///
/// This initializes the symphonia decoder and prepares for incremental decoding.
/// The BufferedMediaSource should already have its initial buffer filled.
///
/// Returns the source along with detected sample_rate and channels.
pub fn new(
buffered_source: Arc<BufferedMediaSource>,
) -> Result<Self, String> {
// Create a reader from the buffered source
let reader = buffered_source.create_reader();
let media_source = Box::new(reader) as Box<dyn MediaSource>;
let mss = MediaSourceStream::new(media_source, Default::default());
let mut hint = Hint::new();
hint.with_extension("flac"); // Most Qobuz Hi-Res is FLAC
let format_opts = FormatOptions {
enable_gapless: true,
..Default::default()
};
let metadata_opts: MetadataOptions = Default::default();
let probed = get_probe()
.format(&hint, mss, &format_opts, &metadata_opts)
.map_err(|err| format!("Symphonia probe failed for streaming: {}", err))?;
let track = probed
.format
.default_track()
.ok_or_else(|| "Symphonia: no supported audio tracks in stream".to_string())?;
let track_id = track.id;
let codec_params = track.codec_params.clone();
// Extract sample rate and channels from codec params
let sample_rate = codec_params
.sample_rate
.ok_or_else(|| "No sample rate in codec params".to_string())?;
let channels = codec_params
.channels
.map(|c| c.count() as u16)
.unwrap_or(2);
let decoder = get_codecs()
.make(&codec_params, &DecoderOptions::default())
.map_err(|err| format!("Symphonia decoder init failed for streaming: {}", err))?;
log::info!(
"IncrementalStreamingSource initialized: {}Hz, {} channels",
sample_rate,
channels
);
Ok(Self {
sample_rate,
channels,
sample_queue: VecDeque::with_capacity(sample_rate as usize * channels as usize), // ~1s buffer
format: probed.format,
decoder,
track_id,
finished: false,
packets_decoded: 0,
buffered_source,
})
}
/// Get the sample rate
pub fn get_sample_rate(&self) -> u32 {
self.sample_rate
}
/// Get the number of channels
pub fn get_channels(&self) -> u16 {
self.channels
}
/// Get reference to the buffered source for cache retrieval
pub fn buffered_source(&self) -> &Arc<BufferedMediaSource> {
&self.buffered_source
}
/// Decode more packets to fill the sample queue.
///
/// This is called when the sample queue is running low.
/// It will decode packets until the queue has at least `min_samples` or EOF is reached.
fn decode_more(&mut self, min_samples: usize) {
if self.finished {
return;
}
while self.sample_queue.len() < min_samples {
let packet = match self.format.next_packet() {
Ok(packet) => packet,
Err(SymphoniaError::IoError(ref e)) if e.kind() == std::io::ErrorKind::WouldBlock => {
// Not enough data buffered yet - wait briefly and retry
// This happens when playback catches up with download
std::thread::sleep(Duration::from_millis(5));
continue;
}
Err(SymphoniaError::IoError(_)) => {
// EOF or other IO error
log::info!(
"IncrementalStreamingSource: EOF reached after {} packets",
self.packets_decoded
);
self.finished = true;
return;
}
Err(err) => {
log::error!("Symphonia read error in stream: {}", err);
self.finished = true;
return;
}
};
if packet.track_id() != self.track_id {
continue;
}
match self.decoder.decode(&packet) {
Ok(audio_buf) => {
let spec = *audio_buf.spec();
let mut sample_buf = SampleBuffer::<i16>::new(audio_buf.frames() as u64, spec);
sample_buf.copy_interleaved_ref(audio_buf);
// Add samples to queue
self.sample_queue.extend(sample_buf.samples().iter().copied());
self.packets_decoded += 1;
}
Err(SymphoniaError::DecodeError(e)) => {
log::warn!("Decode error (skipping packet): {}", e);
continue;
}
Err(SymphoniaError::ResetRequired) => {
self.decoder.reset();
continue;
}
Err(err) => {
log::error!("Symphonia decode error: {}", err);
self.finished = true;
return;
}
}
}
}
}
impl Source for IncrementalStreamingSource {
fn current_frame_len(&self) -> Option<usize> {
// We don't know frame boundaries in the queue
None
}
fn channels(&self) -> u16 {
self.channels
}
fn sample_rate(&self) -> u32 {
self.sample_rate
}
fn total_duration(&self) -> Option<Duration> {
// We don't know total duration until download completes
// Could estimate from content-length if available
None
}
}
impl Iterator for IncrementalStreamingSource {
type Item = i16;
fn next(&mut self) -> Option<Self::Item> {
// If queue is running low, decode more
// Keep at least 0.5 seconds of audio buffered
let min_buffer = (self.sample_rate as usize * self.channels as usize) / 2;
if self.sample_queue.len() < min_buffer {
self.decode_more(min_buffer);
}
self.sample_queue.pop_front()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
#[test]
fn test_basic_read_write() {
let config = StreamingConfig {
initial_buffer_bytes: 10,
max_buffer_bytes: 100,
};
let (mut source, writer) = BufferedMediaSource::new(config, Some(20));
// Write some data
writer.push_chunk(b"Hello").unwrap();
writer.push_chunk(b"World").unwrap();
// Read it back
let mut buf = [0u8; 5];
assert_eq!(source.read(&mut buf).unwrap(), 5);
assert_eq!(&buf, b"Hello");
assert_eq!(source.read(&mut buf).unwrap(), 5);
assert_eq!(&buf, b"World");
}
#[test]
fn test_seek_within_buffer() {
let config = StreamingConfig {
initial_buffer_bytes: 5,
max_buffer_bytes: 100,
};
let (mut source, writer) = BufferedMediaSource::new(config, Some(10));
writer.push_chunk(b"0123456789").unwrap();
writer.complete().unwrap();
// Read first 5 bytes
let mut buf = [0u8; 5];
source.read(&mut buf).unwrap();
assert_eq!(&buf, b"01234");
// Seek back to start
source.seek(SeekFrom::Start(0)).unwrap();
source.read(&mut buf).unwrap();
assert_eq!(&buf, b"01234");
// Seek to middle
source.seek(SeekFrom::Start(3)).unwrap();
source.read(&mut buf).unwrap();
assert_eq!(&buf, b"34567");
}
#[test]
fn test_complete_data_retrieval() {
let config = StreamingConfig {
initial_buffer_bytes: 5,
max_buffer_bytes: 100,
};
let (source, writer) = BufferedMediaSource::new(config, Some(10));
writer.push_chunk(b"Hello").unwrap();
assert!(source.take_complete_data().is_none()); // Not complete yet
writer.push_chunk(b"World").unwrap();
writer.complete().unwrap();
let data = source.take_complete_data().unwrap();
assert_eq!(&data, b"HelloWorld");
}
#[test]
fn test_blocking_read() {
let config = StreamingConfig {
initial_buffer_bytes: 5,
max_buffer_bytes: 100,
};
let (mut source, writer) = BufferedMediaSource::new(config, None);
// Spawn thread to write after delay
thread::spawn(move || {
thread::sleep(Duration::from_millis(50));
writer.push_chunk(b"Delayed").unwrap();
writer.complete().unwrap();
});
// This should block until data arrives
let mut buf = [0u8; 7];
let n = source.read(&mut buf).unwrap();
assert_eq!(n, 7);
assert_eq!(&buf, b"Delayed");
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ pub async fn match_tracks(
for track in tracks {
let query = format!("{} {}", track.artist, track.title);
let search = client
.search_tracks(&query, SEARCH_LIMIT, 0)
.search_tracks(&query, SEARCH_LIMIT, 0, None)
.await
.map_err(|e| PlaylistImportError::Qobuz(e.to_string()))?;
+5 -2
View File
@@ -260,7 +260,8 @@ function toArtistAlbumSummary(album: QobuzAlbum): ArtistAlbumSummary {
title: album.title,
artwork,
year: album.release_date_original?.split('-')[0],
quality
quality,
genre: album.genre?.name || "Unknown genre"
};
}
@@ -284,7 +285,8 @@ function buildCompilationAlbums(tracks: QobuzTrack[] | undefined): ArtistAlbumSu
track.hires_streamable,
track.maximum_bit_depth,
track.maximum_sampling_rate
)
),
genre: album.genre?.name || ''
});
}
@@ -325,6 +327,7 @@ export function convertQobuzAlbum(album: QobuzAlbum): AlbumDetail {
year: album.release_date_original?.split('-')[0] || '',
releaseDate: album.release_date_original,
label: album.label?.name || '',
labelId: album.label?.id,
genre: album.genre?.name || '',
quality,
trackCount: album.tracks_count || album.tracks?.items?.length || 0,
+21 -1
View File
@@ -14,6 +14,7 @@
artwork: string;
title: string;
artist: string;
genre: string;
quality?: string;
size?: 'standard' | 'large';
searchId?: string;
@@ -38,6 +39,7 @@
artwork,
title,
artist,
genre,
quality,
size = 'standard',
searchId,
@@ -182,6 +184,7 @@
<!-- Action Overlay -->
{#if hasOverlay}
<div class="action-overlay" class:menu-open={menuOpen}>
<div class="overlay-genre">{genre}</div>
<div class="action-buttons">
{#if showFavoriteButton}
<button
@@ -322,7 +325,7 @@
justify-content: center;
opacity: 0;
transition: opacity 150ms ease;
background: rgba(10, 10, 10, 0.25);
background: rgba(10, 10, 10, 0.5);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
pointer-events: auto;
@@ -390,6 +393,23 @@
height: 30px;
}
.overlay-genre {
align-self: flex-start;
width: 100%;
text-align: left;
padding-left: 1em;
color: white;
text-shadow:
0.06em 0 black,
0 0.06em black,
-0.06em 0 black,
0 -0.06em black,
-0.06em -0.06em black,
-0.06em 0.06em black,
0.06em -0.06em black,
0.06em 0.06em black;
}
:global(.album-card .album-menu) {
display: flex;
align-items: center;
+31 -2
View File
@@ -9,9 +9,10 @@
onClose: () => void;
onTrackPlay?: (track: TrackCredits) => void;
onPerformerSearch?: (name: string) => void;
onLabelClick?: (labelId: number, labelName: string) => void;
}
let { isOpen, albumId, onClose, onTrackPlay, onPerformerSearch }: Props = $props();
let { isOpen, albumId, onClose, onTrackPlay, onPerformerSearch, onLabelClick }: Props = $props();
function handlePerformerClick(name: string) {
if (onPerformerSearch) {
@@ -121,7 +122,19 @@
{#if credits.album.label}
<p class="meta-row">
<span class="meta-label">Released by</span>
<span class="meta-value label-name">{credits.album.label}</span>
{#if credits.album.label_id && onLabelClick}
<button
class="label-link"
onclick={() => {
onLabelClick!(credits!.album.label_id!, credits!.album.label);
onClose();
}}
>
{credits.album.label}
</button>
{:else}
<span class="meta-value label-name">{credits.album.label}</span>
{/if}
{#if credits.album.release_date}
<span class="meta-date">on {new Date(credits.album.release_date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}</span>
{/if}
@@ -448,6 +461,22 @@
color: var(--text-primary);
}
.label-link {
background: none;
border: none;
padding: 0;
font-size: inherit;
font-weight: 600;
color: var(--text-primary);
cursor: pointer;
transition: color 150ms ease;
}
.label-link:hover {
color: var(--accent-primary);
text-decoration: underline;
}
.meta-date {
display: block;
color: var(--text-muted);
+37 -1
View File
@@ -9,9 +9,10 @@
onClose: () => void;
onArtistClick?: (artistId: number) => void;
onPerformerSearch?: (name: string) => void;
onLabelClick?: (labelId: number, labelName: string) => void;
}
let { isOpen, trackId, onClose, onArtistClick, onPerformerSearch }: Props = $props();
let { isOpen, trackId, onClose, onArtistClick, onPerformerSearch, onLabelClick }: Props = $props();
let loading = $state(false);
let error = $state<string | null>(null);
@@ -187,6 +188,24 @@
<span class="metadata-value mono">{trackInfo.track.isrc}</span>
</div>
{/if}
{#if trackInfo.track.album?.label}
<div class="metadata-item">
<span class="metadata-label">LABEL</span>
{#if onLabelClick}
<button
class="label-link"
onclick={() => {
onLabelClick!(trackInfo!.track.album!.label!.id, trackInfo!.track.album!.label!.name);
onClose();
}}
>
{trackInfo.track.album.label.name}
</button>
{:else}
<span class="metadata-value">{trackInfo.track.album.label.name}</span>
{/if}
</div>
{/if}
</div>
<!-- Credits Grid (2 columns) -->
@@ -465,4 +484,21 @@
font-size: 12px;
color: var(--text-muted);
}
/* Label link */
.label-link {
background: none;
border: none;
padding: 0;
font-size: 14px;
color: var(--accent-primary);
cursor: pointer;
text-align: left;
transition: opacity 150ms ease;
}
.label-link:hover {
opacity: 0.8;
text-decoration: underline;
}
</style>
@@ -312,6 +312,7 @@
artwork={getArtworkUrl(album.artwork_path)}
title={album.title}
artist={album.artist}
genre={"Unknown genre"}
quality={getQualityBadge(album)}
size="large"
showFavorite={true}
@@ -32,6 +32,7 @@
title: string;
artwork: string;
quality: string;
genre: string;
}
interface Props {
@@ -44,6 +45,7 @@
year: string;
releaseDate?: string;
label: string;
labelId?: number;
genre: string;
quality: string;
trackCount: number;
@@ -52,6 +54,7 @@
};
onBack: () => void;
onArtistClick?: () => void;
onLabelClick?: (labelId: number, labelName: string) => void;
onTrackPlay?: (track: Track) => void;
onTrackPlayNext?: (track: Track) => void;
onTrackPlayLater?: (track: Track) => void;
@@ -97,6 +100,7 @@
album,
onBack,
onArtistClick,
onLabelClick,
onTrackPlay,
onTrackPlayNext,
onTrackPlayLater,
@@ -318,7 +322,17 @@
{:else}
<div class="artist-name">{album.artist}</div>
{/if}
<div class="album-info">{formattedReleaseDate} • {album.label} • {album.genre}</div>
<div class="album-info">
{formattedReleaseDate} •
{#if album.labelId && onLabelClick}
<button class="label-link" onclick={() => onLabelClick!(album.labelId!, album.label)}>
{album.label}
</button>
{:else}
{album.label}
{/if}
• {album.genre}
</div>
<div class="album-quality">{album.quality}</div>
<div class="album-stats">{album.trackCount} tracks • {album.duration}</div>
@@ -470,6 +484,7 @@
artwork={relatedAlbum.artwork}
title={relatedAlbum.title}
artist={album.artist}
genre={relatedAlbum.genre}
size="large"
quality={relatedAlbum.quality}
onclick={() => onRelatedAlbumClick?.(relatedAlbum.id)}
@@ -608,6 +623,21 @@
text-decoration: underline;
}
.label-link {
background: none;
border: none;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
transition: color 150ms ease;
}
.label-link:hover {
color: var(--accent-primary);
text-decoration: underline;
}
.album-info {
font-size: 14px;
color: var(--text-muted);
@@ -1443,6 +1443,7 @@
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
genre={album.genre}
size="large"
quality={album.quality}
searchId={`album-${album.id}`}
@@ -1504,6 +1505,7 @@
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
genre={album.genre}
size="large"
quality={album.quality}
searchId={`album-${album.id}`}
@@ -1564,6 +1566,7 @@
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
genre={album.genre}
size="large"
quality={album.quality}
searchId={`album-${album.id}`}
@@ -1624,6 +1627,7 @@
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
genre={album.genre}
size="large"
quality={album.quality}
searchId={`album-${album.id}`}
@@ -1684,6 +1688,7 @@
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
genre={album.genre}
size="large"
quality={album.quality}
searchId={`album-${album.id}`}
@@ -1807,6 +1812,7 @@
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
genre={album.genre}
size="large"
quality={album.quality}
searchId={`album-${album.id}`}
@@ -16,6 +16,7 @@
id: string;
title: string;
artist: { id: number; name: string };
genre?: { name: string };
image: { small?: string; thumbnail?: string; large?: string };
release_date_original?: string;
hires: boolean;
@@ -558,6 +559,10 @@
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function getGenreLabel(album: FavoriteAlbum): string {
return album.genre?.name || 'Unknown genre'
}
function getQualityLabel(item: { hires?: boolean; maximum_bit_depth?: number; maximum_sampling_rate?: number }): string {
if (item.hires && item.maximum_bit_depth && item.maximum_sampling_rate) {
return `${item.maximum_bit_depth}bit/${item.maximum_sampling_rate}kHz`;
@@ -1295,6 +1300,7 @@
artwork={album.image?.large || album.image?.thumbnail || ''}
title={album.title}
artist={album.artist.name}
genre={getGenreLabel(album)}
size="large"
quality={getQualityLabel(album)}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
@@ -1367,6 +1373,7 @@
artwork={album.image?.large || album.image?.thumbnail || ''}
title={album.title}
artist={album.artist.name}
genre={getGenreLabel(album)}
size="large"
quality={getQualityLabel(album)}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
+9
View File
@@ -36,6 +36,7 @@
artwork: string;
title: string;
artist: string;
genre: string;
quality?: string;
}
@@ -332,6 +333,7 @@
artwork: getQobuzImage(album.image),
title: album.title,
artist: album.artist?.name || 'Unknown Artist',
genre: album.genre?.name || 'Unknown genre',
quality: formatQuality(album.hires_streamable, album.maximum_bit_depth, album.maximum_sampling_rate)
};
}
@@ -644,6 +646,7 @@
artwork={album.artwork}
title={album.title}
artist={album.artist}
genre={album.genre}
size="large"
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
@@ -674,6 +677,7 @@
artwork={album.artwork}
title={album.title}
artist={album.artist}
genre={album.genre}
size="large"
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
@@ -704,6 +708,7 @@
artwork={album.artwork}
title={album.title}
artist={album.artist}
genre={album.genre}
size="large"
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
@@ -734,6 +739,7 @@
artwork={album.artwork}
title={album.title}
artist={album.artist}
genre={album.genre}
size="large"
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
@@ -764,6 +770,7 @@
artwork={album.artwork}
title={album.title}
artist={album.artist}
genre={album.genre}
size="large"
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
@@ -794,6 +801,7 @@
artwork={album.artwork}
title={album.title}
artist={album.artist}
genre={album.genre}
size="large"
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
@@ -895,6 +903,7 @@
artwork={album.artwork}
title={album.title}
artist={album.artist}
genre={album.genre}
size="large"
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
+573
View File
@@ -0,0 +1,573 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
import { ArrowLeft, Search, X, Disc3, Loader2 } from 'lucide-svelte';
import AlbumCard from '../AlbumCard.svelte';
import type { QobuzAlbum, LabelDetail } from '$lib/types';
import type { OfflineCacheStatus } from '$lib/stores/offlineCacheState';
interface Props {
labelId: number;
labelName?: string;
onBack: () => void;
onAlbumClick?: (albumId: string) => void;
onAlbumPlay?: (albumId: string) => void;
onAlbumPlayNext?: (albumId: string) => void;
onAlbumPlayLater?: (albumId: string) => void;
onAddAlbumToPlaylist?: (albumId: string) => void;
onAlbumShareQobuz?: (albumId: string) => void;
onAlbumShareSonglink?: (albumId: string) => void;
onAlbumDownload?: (albumId: string) => void;
onOpenAlbumFolder?: (albumId: string) => void;
onReDownloadAlbum?: (albumId: string) => void;
checkAlbumFullyDownloaded?: (albumId: string) => Promise<boolean>;
downloadStateVersion?: number;
}
let {
labelId,
labelName = '',
onBack,
onAlbumClick,
onAlbumPlay,
onAlbumPlayNext,
onAlbumPlayLater,
onAddAlbumToPlaylist,
onAlbumShareQobuz,
onAlbumShareSonglink,
onAlbumDownload,
onOpenAlbumFolder,
onReDownloadAlbum,
checkAlbumFullyDownloaded,
downloadStateVersion
}: Props = $props();
// State
let label = $state<LabelDetail | null>(null);
let albums = $state<QobuzAlbum[]>([]);
let loading = $state(false);
let loadingMore = $state(false);
let error = $state<string | null>(null);
let searchQuery = $state('');
let searchExpanded = $state(false);
let totalAlbums = $state(0);
let albumsFetched = $state(0);
// Download status tracking
let albumOfflineCacheStatuses = $state<Map<string, boolean>>(new Map());
// Derived: filtered albums based on search
let filteredAlbums = $derived(
searchQuery.trim()
? albums.filter(album =>
album.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
album.artist?.name?.toLowerCase().includes(searchQuery.toLowerCase())
)
: albums
);
async function loadLabel() {
loading = true;
error = null;
try {
const result = await invoke<{
id: number;
name: string;
description?: string;
image?: { small?: string; thumbnail?: string; large?: string };
albums?: { items: QobuzAlbum[]; total: number; offset: number; limit: number };
albums_count?: number;
}>('get_label', { labelId, limit: 100, offset: 0 });
label = {
id: result.id,
name: result.name,
description: result.description,
image: result.image,
albums: result.albums?.items ?? [],
totalAlbums: result.albums?.total ?? result.albums_count ?? 0,
albumsFetched: result.albums?.items?.length ?? 0
};
albums = label.albums;
totalAlbums = label.totalAlbums;
albumsFetched = label.albumsFetched;
// Load download statuses
await loadAllAlbumOfflineCacheStatuses(albums);
} catch (e) {
console.error('Failed to load label:', e);
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function loadMore() {
if (loadingMore || albumsFetched >= totalAlbums) return;
loadingMore = true;
try {
const result = await invoke<{
id: number;
name: string;
albums?: { items: QobuzAlbum[]; total: number; offset: number; limit: number };
}>('get_label', { labelId, limit: 100, offset: albumsFetched });
const newAlbums = result.albums?.items ?? [];
albums = [...albums, ...newAlbums];
albumsFetched += newAlbums.length;
// Load download statuses for new albums
await loadAllAlbumOfflineCacheStatuses(newAlbums);
} catch (e) {
console.error('Failed to load more albums:', e);
} finally {
loadingMore = false;
}
}
async function loadAlbumOfflineCacheStatus(albumId: string) {
if (!checkAlbumFullyDownloaded) return false;
try {
const isDownloaded = await checkAlbumFullyDownloaded(albumId);
albumOfflineCacheStatuses.set(albumId, isDownloaded);
return isDownloaded;
} catch {
return false;
}
}
async function loadAllAlbumOfflineCacheStatuses(albumList: QobuzAlbum[]) {
if (!checkAlbumFullyDownloaded || albumList.length === 0) return;
await Promise.all(albumList.map(album => loadAlbumOfflineCacheStatus(album.id)));
}
function isAlbumDownloaded(albumId: string): boolean {
void downloadStateVersion;
return albumOfflineCacheStatuses.get(albumId) || false;
}
function getQualityLabel(album: QobuzAlbum): string {
if (album.hires_streamable) {
const bitDepth = album.maximum_bit_depth || 24;
const sampleRate = album.maximum_sampling_rate || 96;
return `${bitDepth}-bit/${sampleRate}kHz`;
}
return '';
}
function getGenreLabel(album: QobuzAlbum): string {
return album.genre?.name || '';
}
function clearSearch() {
searchQuery = '';
}
function toggleSearch() {
searchExpanded = !searchExpanded;
if (!searchExpanded) {
searchQuery = '';
}
}
// Scroll handler for infinite loading
function handleScroll(e: Event) {
const target = e.target as HTMLElement;
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
if (scrollBottom < 400 && !loadingMore && albumsFetched < totalAlbums) {
loadMore();
}
}
onMount(() => {
loadLabel();
});
</script>
<div class="label-view" onscroll={handleScroll}>
<!-- Header -->
<header class="header">
<button class="back-btn" onclick={onBack} title="Go back">
<ArrowLeft size={20} />
</button>
<div class="header-icon">
<Disc3 size={36} strokeWidth={1.5} />
</div>
<div class="header-content">
<h1>{label?.name || labelName || 'Label'}</h1>
<p class="subtitle">
{#if loading}
Loading...
{:else if totalAlbums > 0}
{totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
{/if}
</p>
</div>
</header>
<!-- Fixed Navigation/Search Bar -->
<nav class="label-nav">
<div class="nav-left">
<span class="nav-title">Albums</span>
{#if albumsFetched > 0 && albumsFetched < totalAlbums}
<span class="nav-count">Showing {albumsFetched} of {totalAlbums}</span>
{/if}
</div>
<div class="nav-right">
{#if searchExpanded}
<div class="search-expanded">
<Search size={16} class="search-icon-inline" />
<input
type="text"
class="search-input-inline"
placeholder="Search albums..."
bind:value={searchQuery}
autofocus
/>
{#if searchQuery}
<button class="search-clear-btn" onclick={clearSearch}>
<X size={14} />
</button>
{/if}
<button class="search-close-btn" onclick={toggleSearch}>
<X size={16} />
</button>
</div>
{:else}
<button class="search-icon-btn" onclick={toggleSearch} title="Search albums">
<Search size={18} />
</button>
{/if}
</div>
</nav>
<!-- Content -->
<div class="content">
{#if loading}
<div class="loading">
<div class="spinner"></div>
<p>Loading label...</p>
</div>
{:else if error}
<div class="error">
<p>Failed to load label</p>
<p class="error-detail">{error}</p>
<button class="retry-btn" onclick={loadLabel}>Retry</button>
</div>
{:else if albums.length === 0}
<div class="empty">
<Disc3 size={48} />
<p>No albums found for this label</p>
</div>
{:else if filteredAlbums.length === 0}
<div class="empty">
<Search size={48} />
<p>No albums match "{searchQuery}"</p>
</div>
{:else}
<div class="album-grid">
{#each filteredAlbums as album (album.id)}
<AlbumCard
albumId={album.id}
artwork={album.image?.large || album.image?.thumbnail || ''}
title={album.title}
artist={album.artist?.name || 'Unknown Artist'}
genre={getGenreLabel(album)}
quality={getQualityLabel(album)}
size="large"
onclick={() => onAlbumClick?.(album.id)}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
isAlbumFullyDownloaded={isAlbumDownloaded(album.id)}
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
{downloadStateVersion}
/>
{/each}
</div>
{#if loadingMore}
<div class="loading-more">
<Loader2 size={20} class="spinner-icon" />
<span>Loading more albums...</span>
</div>
{/if}
{/if}
</div>
</div>
<style>
.label-view {
padding: 24px;
padding-left: 18px;
padding-right: 8px;
padding-bottom: 100px;
overflow-y: auto;
height: 100%;
}
/* Custom scrollbar */
.label-view::-webkit-scrollbar {
width: 6px;
}
.label-view::-webkit-scrollbar-track {
background: transparent;
}
.label-view::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 3px;
}
.label-view::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
}
.back-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 150ms ease;
}
.back-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.header-icon {
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 16px;
color: white;
}
.header-content {
flex: 1;
}
.header-content h1 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 4px 0;
}
.subtitle {
font-size: 14px;
color: var(--text-muted);
margin: 0;
}
.label-nav {
position: sticky;
top: -24px;
z-index: 4;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 12px 24px;
margin: 0 -8px 16px -24px;
width: calc(100% + 32px);
background: var(--bg-primary);
border-bottom: 1px solid var(--alpha-6);
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.5);
}
.nav-left {
display: flex;
align-items: center;
gap: 12px;
}
.nav-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.nav-count {
font-size: 12px;
color: var(--text-muted);
}
.nav-right {
display: flex;
align-items: center;
gap: 8px;
}
.search-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
transition: all 150ms ease;
}
.search-icon-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.search-expanded {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
min-width: 280px;
}
:global(.search-icon-inline) {
color: var(--text-muted);
flex-shrink: 0;
}
.search-input-inline {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: 13px;
}
.search-input-inline::placeholder {
color: var(--text-muted);
}
.search-clear-btn,
.search-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
background: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 4px;
transition: all 150ms ease;
flex-shrink: 0;
}
.search-clear-btn:hover,
.search-close-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.content {
min-height: 200px;
}
.loading,
.error,
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
color: var(--text-muted);
text-align: center;
}
.loading p,
.error p,
.empty p {
margin: 16px 0 0 0;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--bg-tertiary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-detail {
font-size: 12px;
margin-top: 8px;
}
.retry-btn {
margin-top: 16px;
padding: 8px 24px;
background-color: var(--accent-primary);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
.retry-btn:hover {
opacity: 0.9;
}
.album-grid {
display: flex;
flex-wrap: wrap;
gap: 24px 14px;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
color: var(--text-muted);
font-size: 13px;
}
:global(.spinner-icon) {
animation: spin 1s linear infinite;
}
</style>
+209 -64
View File
@@ -4,7 +4,7 @@
import { Search, Disc3, Music, Mic2, User, X, ChevronLeft, ChevronRight, Crown } from 'lucide-svelte';
import AlbumCard from '../AlbumCard.svelte';
import TrackMenu from '../TrackMenu.svelte';
import { getSearchState, setSearchState, type SearchResults, type SearchAllResults, type SearchTab } from '$lib/stores/searchState';
import { getSearchState, setSearchState, type SearchResults, type SearchAllResults, type SearchTab, type SearchFilterType } from '$lib/stores/searchState';
import { setPlaybackContext } from '$lib/stores/playbackContextStore';
import { togglePlay } from '$lib/stores/playerStore';
import { t } from '$lib/i18n';
@@ -140,6 +140,7 @@
id: string;
title: string;
artist: { name: string };
genre?: { name: string; };
image: { small?: string; thumbnail?: string; large?: string };
release_date_original?: string;
hires_streamable?: boolean;
@@ -174,6 +175,7 @@
let query = $state(cachedState.query ?? '');
let activeTab = $state<SearchTab>(cachedState.activeTab ?? 'all');
let filterType = $state<SearchFilterType>(cachedState.filterType ?? null);
let isSearching = $state(false);
let searchError = $state<string | null>(null);
@@ -193,10 +195,13 @@
function debounceSearch() {
if (searchTimeout) clearTimeout(searchTimeout);
// Reset filter when user types a new search
filterType = null;
if (query.trim().length < 2) {
albumResults = null;
trackResults = null;
artistResults = null;
allResults = null;
return;
}
searchTimeout = setTimeout(() => performSearch(), 300);
@@ -208,6 +213,7 @@
searchTimeout = null;
}
query = '';
filterType = null;
searchError = null;
isSearching = false;
albumResults = null;
@@ -231,6 +237,7 @@
setSearchState<Album, Track, Artist>({
query,
activeTab,
filterType,
albumResults,
trackResults,
artistResults,
@@ -281,7 +288,8 @@
// Search based on active tab - reset to first page
if (activeTab === 'all') {
allResults = await invoke<SearchAllResults<Album, Track, Artist>>('search_all', {
query: query.trim()
query: query.trim(),
searchType: filterType
});
console.log('All results:', allResults);
if (allResults && allResults.albums.items) {
@@ -291,7 +299,8 @@
albumResults = await invoke<SearchResults<Album>>('search_albums', {
query: query.trim(),
limit: PAGE_SIZE,
offset: 0
offset: 0,
searchType: filterType
});
console.log('Album results:', albumResults);
if (albumResults && albumResults.items) {
@@ -301,14 +310,16 @@
trackResults = await invoke<SearchResults<Track>>('search_tracks', {
query: query.trim(),
limit: PAGE_SIZE,
offset: 0
offset: 0,
searchType: filterType
});
console.log('Track results:', trackResults);
} else if (activeTab === 'artists') {
artistResults = await invoke<SearchResults<Artist>>('search_artists', {
query: query.trim(),
limit: PAGE_SIZE,
offset: 0
offset: 0,
searchType: filterType
});
console.log('Artist results:', artistResults);
}
@@ -331,7 +342,8 @@
const moreResults = await invoke<SearchResults<Album>>('search_albums', {
query: query.trim(),
limit: PAGE_SIZE,
offset: newOffset
offset: newOffset,
searchType: filterType
});
await loadAllAlbumDownloadStatuses(moreResults.items);
albumResults = {
@@ -344,7 +356,8 @@
const moreResults = await invoke<SearchResults<Track>>('search_tracks', {
query: query.trim(),
limit: PAGE_SIZE,
offset: newOffset
offset: newOffset,
searchType: filterType
});
trackResults = {
...moreResults,
@@ -356,7 +369,8 @@
const moreResults = await invoke<SearchResults<Artist>>('search_artists', {
query: query.trim(),
limit: PAGE_SIZE,
offset: newOffset
offset: newOffset,
searchType: filterType
});
artistResults = {
...moreResults,
@@ -378,12 +392,36 @@
}
}
// Filter functions
function setFilter(type: SearchFilterType) {
filterType = type;
if (query.trim().length >= 2) {
performSearch();
}
}
function clearFilter() {
filterType = null;
if (query.trim().length >= 2) {
performSearch();
}
}
// Check if we have any results to show filters
let hasResults = $derived(
!!(allResults || albumResults || trackResults || artistResults)
);
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function getGenreLabel(album: Album): string {
return album.genre?.name || 'Unknown genre'
}
function getQualityLabel(track: Track | Album): string {
if (track.hires_streamable && track.maximum_bit_depth && track.maximum_sampling_rate) {
return `${track.maximum_bit_depth}bit/${track.maximum_sampling_rate}kHz`;
@@ -532,55 +570,102 @@
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button
class="tab"
class:active={activeTab === 'all'}
onclick={() => handleTabChange('all')}
>
<Search size={18} />
<span>All</span>
</button>
<button
class="tab"
class:active={activeTab === 'albums'}
onclick={() => handleTabChange('albums')}
>
<Disc3 size={18} />
<span>{$t('search.albums')}</span>
{#if albumResults}
<span class="count">{albumResults.total}</span>
{:else if allResults}
<span class="count">{allResults.albums.total}</span>
{/if}
</button>
<button
class="tab"
class:active={activeTab === 'tracks'}
onclick={() => handleTabChange('tracks')}
>
<Music size={18} />
<span>{$t('search.tracks')}</span>
{#if trackResults}
<span class="count">{trackResults.total}</span>
{:else if allResults}
<span class="count">{allResults.tracks.total}</span>
{/if}
</button>
<button
class="tab"
class:active={activeTab === 'artists'}
onclick={() => handleTabChange('artists')}
>
<Mic2 size={18} />
<span>{$t('search.artists')}</span>
{#if artistResults}
<span class="count">{artistResults.total}</span>
{:else if allResults}
<span class="count">{allResults.artists.total}</span>
{/if}
</button>
<!-- Tabs and Filters Row -->
<div class="tabs-row">
<div class="tabs">
<button
class="tab"
class:active={activeTab === 'all'}
onclick={() => handleTabChange('all')}
>
<Search size={18} />
<span>All</span>
</button>
<button
class="tab"
class:active={activeTab === 'albums'}
onclick={() => handleTabChange('albums')}
>
<Disc3 size={18} />
<span>{$t('search.albums')}</span>
</button>
<button
class="tab"
class:active={activeTab === 'tracks'}
onclick={() => handleTabChange('tracks')}
>
<Music size={18} />
<span>{$t('search.tracks')}</span>
</button>
<button
class="tab"
class:active={activeTab === 'artists'}
onclick={() => handleTabChange('artists')}
>
<Mic2 size={18} />
<span>{$t('search.artists')}</span>
</button>
</div>
{#if hasResults}
<div class="filters">
<label class="filter-option">
<input
type="radio"
name="searchFilter"
checked={filterType === 'MainArtist'}
onchange={() => setFilter('MainArtist')}
/>
<span>Artist</span>
</label>
<label class="filter-option">
<input
type="radio"
name="searchFilter"
checked={filterType === 'Performer'}
onchange={() => setFilter('Performer')}
/>
<span>Performer</span>
</label>
<label class="filter-option">
<input
type="radio"
name="searchFilter"
checked={filterType === 'Composer'}
onchange={() => setFilter('Composer')}
/>
<span>Composer</span>
</label>
<label class="filter-option">
<input
type="radio"
name="searchFilter"
checked={filterType === 'Label'}
onchange={() => setFilter('Label')}
/>
<span>Label</span>
</label>
<label class="filter-option">
<input
type="radio"
name="searchFilter"
checked={filterType === 'ReleaseName'}
onchange={() => setFilter('ReleaseName')}
/>
<span>Release</span>
</label>
<button
class="clear-filter-btn"
class:visible={filterType !== null}
onclick={clearFilter}
title="Clear filter"
disabled={!filterType}
>
<X size={14} />
</button>
</div>
{/if}
</div>
<!-- Results -->
@@ -640,6 +725,7 @@
artwork={getAlbumArtwork(allResults.albums.items[0])}
title={allResults.albums.items[0].title}
artist={allResults.albums.items[0].artist?.name || 'Unknown Artist'}
genre={getGenreLabel(allResults.albums.items[0])}
size="large"
quality={getQualityLabel(allResults.albums.items[0])}
onPlay={onAlbumPlay ? () => onAlbumPlay(allResults.albums.items[0].id) : undefined}
@@ -776,6 +862,7 @@
artwork={getAlbumArtwork(album)}
title={album.title}
artist={album.artist?.name || 'Unknown Artist'}
genre={getGenreLabel(album)}
size="large"
quality={getQualityLabel(album)}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
@@ -915,6 +1002,7 @@
artwork={getAlbumArtwork(album)}
title={album.title}
artist={album.artist?.name || 'Unknown Artist'}
genre={getGenreLabel(album)}
size="large"
quality={getQualityLabel(album)}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
@@ -1176,14 +1264,21 @@
color: var(--text-primary);
}
.tabs {
.tabs-row {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
border-bottom: 1px solid var(--bg-tertiary);
padding-bottom: 16px;
}
.tabs {
display: flex;
gap: 8px;
}
.tab {
display: flex;
align-items: center;
@@ -1209,11 +1304,61 @@
border-bottom-color: var(--accent-primary);
}
.tab .count {
padding: 2px 8px;
background-color: var(--bg-tertiary);
border-radius: 12px;
font-size: 12px;
/* Filters */
.filters {
display: flex;
align-items: center;
gap: 16px;
}
.filter-option {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: color 150ms ease;
}
.filter-option input[type="radio"] {
accent-color: var(--accent-primary);
cursor: pointer;
margin: 0;
}
.filter-option:has(input:checked) {
color: var(--text-primary);
}
.filter-option:hover {
color: var(--text-primary);
}
.clear-filter-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--bg-tertiary);
border: none;
color: var(--text-muted);
cursor: pointer;
transition: all 150ms ease;
opacity: 0;
pointer-events: none;
}
.clear-filter-btn.visible {
opacity: 1;
pointer-events: auto;
}
.clear-filter-btn.visible:hover {
background: var(--bg-quaternary, var(--bg-tertiary));
color: var(--text-primary);
}
.results {
@@ -248,6 +248,8 @@
let selectedBackend = $state<string>('Auto');
let selectedAlsaPlugin = $state<string>('hw (Direct Hardware)');
let alsaHardwareVolume = $state(false);
let streamFirstTrack = $state(false);
let streamBufferSeconds = $state(3);
// Backend system state
let availableBackends = $state<BackendInfo[]>([]);
@@ -886,6 +888,8 @@
backend_type: 'PipeWire' | 'Alsa' | 'Pulse' | null;
alsa_plugin: 'Hw' | 'PlugHw' | 'Pcm' | null;
alsa_hardware_volume: boolean;
stream_first_track: boolean;
stream_buffer_seconds: number;
}
interface BackendInfo {
@@ -1031,6 +1035,8 @@
}
alsaHardwareVolume = settings.alsa_hardware_volume ?? false;
streamFirstTrack = settings.stream_first_track ?? false;
streamBufferSeconds = settings.stream_buffer_seconds ?? 3;
// Validate mutual exclusion: DAC Passthrough disables Gapless + Crossfade
if (dacPassthrough) {
@@ -1197,6 +1203,28 @@
}
}
async function handleStreamFirstTrackChange(enabled: boolean) {
streamFirstTrack = enabled;
try {
await invoke('set_audio_stream_first_track', { enabled });
console.log('[Audio] Stream first track changed:', enabled);
} catch (err) {
console.error('[Audio] Failed to change stream first track:', err);
}
}
async function handleStreamBufferSecondsChange(seconds: number) {
// Clamp to valid range
const clamped = Math.max(1, Math.min(10, Math.round(seconds)));
streamBufferSeconds = clamped;
try {
await invoke('set_audio_stream_buffer_seconds', { seconds: clamped });
console.log('[Audio] Stream buffer seconds changed:', clamped);
} catch (err) {
console.error('[Audio] Failed to change stream buffer seconds:', err);
}
}
async function handleBackendDeviceChange(deviceName: string) {
outputDevice = deviceName;
@@ -1651,6 +1679,30 @@
</div>
</div>
{/if}
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Stream Uncached Tracks</span>
<span class="setting-desc">Start playback faster when track is not in cache. Seeking may be limited during initial buffering.</span>
</div>
<Toggle enabled={streamFirstTrack} onchange={handleStreamFirstTrackChange} />
</div>
{#if streamFirstTrack}
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Initial Buffer Size</span>
<span class="setting-desc">Seconds to buffer before starting playback ({streamBufferSeconds}s)</span>
</div>
<input
type="range"
min="1"
max="10"
step="1"
value={streamBufferSeconds}
oninput={(e) => handleStreamBufferSecondsChange(parseInt(e.currentTarget.value))}
class="buffer-slider"
/>
</div>
{/if}
<div class="setting-row last">
<span class="setting-label">{$t('settings.audio.currentSampleRate')}</span>
<span class="setting-value" class:muted={!hardwareStatus?.is_active}>
@@ -2896,6 +2948,41 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
color: rgb(251, 191, 36);
font-weight: 600;
}
/* Buffer slider styling */
.buffer-slider {
width: 120px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: var(--alpha-20);
border-radius: 2px;
cursor: pointer;
}
.buffer-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: var(--accent-color, #3b82f6);
border-radius: 50%;
cursor: pointer;
transition: transform 0.1s ease;
}
.buffer-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
.buffer-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: var(--accent-color, #3b82f6);
border-radius: 50%;
cursor: pointer;
border: none;
}
</style>
<MigrationModal
+1 -1
View File
@@ -114,7 +114,7 @@ export async function playTrack(
if (isLocal) {
await invoke('library_play_track', { trackId: track.id });
} else {
await invoke('play_track', { trackId: track.id });
await invoke('play_track', { trackId: track.id, durationSecs: track.duration });
}
}
+1
View File
@@ -14,6 +14,7 @@ export type ViewType =
| 'settings'
| 'album'
| 'artist'
| 'label'
| 'playlist'
| 'playlist-manager'
| 'favorites-tracks'
+2 -2
View File
@@ -245,8 +245,8 @@ export async function togglePlay(): Promise<void> {
console.log('[Player] Restoring local track:', localTrackId);
await invoke('library_play_track', { trackId: localTrackId });
} else {
// Qobuz track - use play_track
await invoke('play_track', { trackId: currentTrack.id });
// Qobuz track - use play_track with duration for seekbar
await invoke('play_track', { trackId: currentTrack.id, durationSecs: currentTrack.duration });
}
// Seek to saved position after a short delay to let audio load
+5
View File
@@ -1,5 +1,8 @@
export type SearchTab = 'all' | 'albums' | 'tracks' | 'artists';
// Search filter types supported by Qobuz API
export type SearchFilterType = 'MainArtist' | 'Performer' | 'Composer' | 'Label' | 'ReleaseName' | null;
export interface SearchResults<T> {
items: T[];
total: number;
@@ -16,6 +19,7 @@ export interface SearchAllResults<Album, Track, Artist> {
export interface SearchState<Album, Track, Artist> {
query: string;
activeTab: SearchTab;
filterType: SearchFilterType;
albumResults: SearchResults<Album> | null;
trackResults: SearchResults<Track> | null;
artistResults: SearchResults<Artist> | null;
@@ -25,6 +29,7 @@ export interface SearchState<Album, Track, Artist> {
let searchState: SearchState<unknown, unknown, unknown> = {
query: '',
activeTab: 'all',
filterType: null,
albumResults: null,
trackResults: null,
artistResults: null,
+21 -1
View File
@@ -21,6 +21,8 @@ export interface QobuzTrack {
id?: string;
title: string;
image?: QobuzImage;
label?: { id: number; name: string };
genre?: { name: string };
};
performer?: { id?: number; name: string };
hires_streamable?: boolean;
@@ -59,6 +61,7 @@ export interface AlbumInfo {
year: string;
release_date?: string;
label: string;
label_id?: number;
genre: string;
quality: string;
track_count: number;
@@ -90,7 +93,7 @@ export interface QobuzAlbum {
hires_streamable?: boolean;
tracks_count?: number;
duration?: number;
label?: { name: string };
label?: { id?: number; name: string };
genre?: { name: string };
maximum_bit_depth?: number;
maximum_sampling_rate?: number;
@@ -161,6 +164,7 @@ export interface AlbumDetail {
year: string;
releaseDate?: string; // Full date in YYYY-MM-DD format
label: string;
labelId?: number;
genre: string;
quality: string;
trackCount: number;
@@ -185,6 +189,7 @@ export interface ArtistDetail {
artwork: string;
year?: string;
quality: string;
genre: string;
}[];
epsSingles: {
id: string;
@@ -192,6 +197,7 @@ export interface ArtistDetail {
artwork: string;
year?: string;
quality: string;
genre: string;
}[];
liveAlbums: {
id: string;
@@ -199,6 +205,7 @@ export interface ArtistDetail {
artwork: string;
year?: string;
quality: string;
genre: string;
}[];
compilations: {
id: string;
@@ -206,6 +213,7 @@ export interface ArtistDetail {
artwork: string;
year?: string;
quality: string;
genre: string;
}[];
tributes: {
id: string;
@@ -213,6 +221,7 @@ export interface ArtistDetail {
artwork: string;
year?: string;
quality: string;
genre: string;
}[];
others: {
id: string;
@@ -220,6 +229,7 @@ export interface ArtistDetail {
artwork: string;
year?: string;
quality: string;
genre: string;
}[];
playlists: {
id: number;
@@ -232,6 +242,16 @@ export interface ArtistDetail {
albumsFetched: number;
}
export interface LabelDetail {
id: number;
name: string;
description?: string;
image?: QobuzImage;
albums: QobuzAlbum[];
totalAlbums: number;
albumsFetched: number;
}
export interface PlaylistTrack {
id: number;
number: number;
+36 -4
View File
@@ -177,6 +177,7 @@
Track,
AlbumDetail,
ArtistDetail,
LabelDetail,
PlaylistTrack,
DisplayTrack,
LocalLibraryTrack,
@@ -274,6 +275,7 @@
import SettingsView from '$lib/components/views/SettingsView.svelte';
import AlbumDetailView from '$lib/components/views/AlbumDetailView.svelte';
import ArtistDetailView from '$lib/components/views/ArtistDetailView.svelte';
import LabelView from '$lib/components/views/LabelView.svelte';
import PlaylistDetailView from '$lib/components/views/PlaylistDetailView.svelte';
import FavoritesView from '$lib/components/views/FavoritesView.svelte';
import LocalLibraryView from '$lib/components/views/LocalLibraryView.svelte';
@@ -318,13 +320,14 @@
// View State (from navigationStore subscription)
let activeView = $state<ViewType>('home');
let selectedPlaylistId = $state<number | null>(null);
// Album and Artist data are fetched, so kept local
// Album, Artist and Label data are fetched, so kept local
let selectedAlbum = $state<AlbumDetail | null>(null);
let selectedArtist = $state<ArtistDetail | null>(null);
let selectedLabel = $state<{ id: number; name: string } | null>(null);
let isArtistAlbumsLoading = $state(false);
// Artist albums for "By the same artist" section in album view
let albumArtistAlbums = $state<{ id: string; title: string; artwork: string; quality: string }[]>([]);
let albumArtistAlbums = $state<{ id: string; title: string; artwork: string; quality: string; genre: string }[]>([]);
// Overlay States (from uiStore subscription)
let isQueueOpen = $state(false);
@@ -444,13 +447,15 @@
id: a.id,
title: a.title,
artwork: a.artwork,
quality: a.quality
quality: a.quality,
genre: a.genre
})),
...artistDetail.liveAlbums.map(a => ({
id: a.id,
title: a.title,
artwork: a.artwork,
quality: a.quality
quality: a.quality,
genre: a.genre
}))
].slice(0, 16);
@@ -496,6 +501,11 @@
}
}
function handleLabelClick(labelId: number, labelName?: string) {
selectedLabel = { id: labelId, name: labelName || '' };
navigateTo('label');
}
/**
* Search for a performer by name (from track credits)
*/
@@ -504,6 +514,7 @@
setSearchState({
query: name,
activeTab: 'all',
filterType: null,
albumResults: null,
trackResults: null,
artistResults: null,
@@ -2498,6 +2509,7 @@
isPlaybackActive={isPlaying}
onBack={navGoBack}
onArtistClick={() => selectedAlbum?.artistId && handleArtistClick(selectedAlbum.artistId)}
onLabelClick={handleLabelClick}
onTrackPlay={handleAlbumTrackPlay}
onTrackPlayNext={handleAlbumTrackPlayNext}
onTrackPlayLater={handleAlbumTrackPlayLater}
@@ -2566,6 +2578,24 @@
activeTrackId={currentTrack?.id ?? null}
isPlaybackActive={isPlaying}
/>
{:else if activeView === 'label' && selectedLabel}
<LabelView
labelId={selectedLabel.id}
labelName={selectedLabel.name}
onBack={navGoBack}
onAlbumClick={handleAlbumClick}
onAlbumPlay={playAlbumById}
onAlbumPlayNext={queueAlbumNextById}
onAlbumPlayLater={queueAlbumLaterById}
onAddAlbumToPlaylist={addAlbumToPlaylistById}
onAlbumShareQobuz={shareAlbumQobuzLinkById}
onAlbumShareSonglink={shareAlbumSonglinkById}
onAlbumDownload={downloadAlbumById}
onOpenAlbumFolder={openAlbumFolderById}
onReDownloadAlbum={reDownloadAlbumById}
checkAlbumFullyDownloaded={checkAlbumFullyDownloaded}
{downloadStateVersion}
/>
{:else if activeView === 'library' || activeView === 'library-album'}
<LocalLibraryView
onTrackPlay={handleLocalTrackPlay}
@@ -2887,6 +2917,7 @@
trackInfoTrackId = null;
}}
onArtistClick={handleArtistClick}
onLabelClick={handleLabelClick}
/>
<!-- Album Credits Modal -->
@@ -2906,6 +2937,7 @@
}
}
}}
onLabelClick={handleLabelClick}
/>
<!-- Cast Picker -->