diff --git a/src-tauri/src/connectivity/mod.rs b/src-tauri/src/connectivity/mod.rs index fe50d59..84fefdb 100644 --- a/src-tauri/src/connectivity/mod.rs +++ b/src-tauri/src/connectivity/mod.rs @@ -82,6 +82,7 @@ impl ConnectivityReporter { /// Current reachability as seen by this reporter (shared with the monitor /// and the UI). Useful for callers that want to branch on connectivity. + #[allow(dead_code)] // public API; currently only exercised by cross-module tests pub async fn is_reachable(&self) -> bool { self.status.read().await.is_server_reachable } diff --git a/src-tauri/src/player/events.rs b/src-tauri/src/player/events.rs index 70ab5e9..343b9b9 100644 --- a/src-tauri/src/player/events.rs +++ b/src-tauri/src/player/events.rs @@ -22,7 +22,14 @@ use super::{MediaSessionType, SleepTimerMode}; /// /// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047 #[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)] -#[serde(tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase")] +// NOTE: fields are intentionally snake_case on the wire. specta generates the +// TypeScript bindings with snake_case field names (it does not apply serde's +// `rename_all_fields`), so adding `rename_all_fields = "camelCase"` here makes +// serde emit camelCase payloads that no longer match the generated schema — +// tauri-specta then silently drops those events (e.g. state_changed, +// queue_changed never reach the frontend, so the mini player never appears). +// Keep serde and specta agreeing: snake_case fields, snake_case variant tags. +#[serde(tag = "type", rename_all = "snake_case")] pub enum PlayerStatusEvent { /// Playback position updated (emitted periodically during playback) PositionUpdate { diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index dfce39a..360369c 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -830,41 +830,57 @@ impl MediaRepository for OfflineRepository { } async fn get_genres(&self, parent_id: Option<&str>) -> Result, RepoError> { - // Extract unique genres from cached items - let mut genre_set = std::collections::HashSet::new(); - + // Derive genres from cached albums, tallying how many albums carry each + // so the frontend can rank by popularity. We scope to MusicAlbum (genres + // power the music landing) and read every matching row — NOT DISTINCT — + // so the per-genre counts are real. Genres are stored as a JSON array + // string per item. let (sql, params) = if let Some(pid) = parent_id { ( - "SELECT DISTINCT genres FROM items WHERE server_id = ? AND library_id = ? AND genres IS NOT NULL", + "SELECT genres FROM items WHERE server_id = ? AND library_id = ? \ + AND item_type = 'MusicAlbum' AND genres IS NOT NULL", vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(pid.to_string()), - ] + ], ) } else { ( - "SELECT DISTINCT genres FROM items WHERE server_id = ? AND genres IS NOT NULL", - vec![QueryParam::String(self.server_id.clone())] + "SELECT genres FROM items WHERE server_id = ? \ + AND item_type = 'MusicAlbum' AND genres IS NOT NULL", + vec![QueryParam::String(self.server_id.clone())], ) }; let query = Query::with_params(sql, params); - let genres_rows: Vec = self.db_service.query_many(query, |row| row.get(0)) + let genres_rows: Vec = self + .db_service + .query_many(query, |row| row.get(0)) .await .map_err(|e| RepoError::Database { message: e })?; + // genre name -> album count + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); for genres_json in genres_rows { if let Ok(genres_vec) = serde_json::from_str::>(&genres_json) { + // De-dupe within one album so a genre listed twice counts once. + let mut seen = std::collections::HashSet::new(); for genre in genres_vec { - genre_set.insert(genre); + if seen.insert(genre.clone()) { + *counts.entry(genre).or_insert(0) += 1; + } } } } - let genres = genre_set + let genres = counts .into_iter() - .map(|name| Genre { id: name.clone(), name, album_count: None }) + .map(|(name, count)| Genre { + id: name.clone(), + name, + album_count: Some(count), + }) .collect(); Ok(genres) diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index 9d2c356..0b7e4d0 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -580,8 +580,10 @@ impl MediaRepository for OnlineRepository { } } - // Request image fields for list views (People only needed in get_item detail view) - endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags"); + // Request image fields for list views (People only needed in get_item + // detail view). Genres is needed so cached items carry their genres, + // which lets the offline store derive genre lists + per-genre counts. + endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres"); let response: ItemsResponse = self.get_json(&endpoint).await?; @@ -854,10 +856,17 @@ impl MediaRepository for OnlineRepository { .collect(); let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count(); - log::debug!( - "get_genres: {} genres, {} carry counts (AlbumCount/ChildCount)", + // TEMP DIAGNOSTIC: dump the first few genres with their counts so we can + // see whether the server populates any count field. Remove once known. + log::warn!( + "get_genres: {} genres, {} carry counts. sample: {:?}", genres.len(), - with_counts + with_counts, + genres + .iter() + .take(8) + .map(|g| (g.name.as_str(), g.album_count)) + .collect::>() ); Ok(genres) @@ -890,8 +899,9 @@ impl MediaRepository for OnlineRepository { } } - // Request image fields for list views (People only needed in get_item detail view) - endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags"); + // Request image fields for list views (plus Genres so cached items + // carry genres for offline genre lists/counts). + endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres"); let response: ItemsResponse = self.get_json(&endpoint).await?; Ok(SearchResult { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index fccadc7..7e545ce 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -25,18 +25,22 @@ let { children } = $props(); onMount(async () => { - // Initialize auth state (restore session from secure storage) - await auth.initialize(); - isInitialized.set(true); - - // Detect platform (Android needs global mini player) + // Detect platform first (synchronously, before any await) so the global + // mini player's Android visibility gate is correct from the first render. + // Android needs the global mini player because the library layout hides its + // own; if this ran after `await auth.initialize()` the store stayed false + // long enough that the mini player never appeared on library routes. try { - const platformName = await platform(); + const platformName = platform(); isAndroid.set(platformName === "android"); } catch (err) { console.error("Platform detection failed:", err); } + // Initialize auth state (restore session from secure storage) + await auth.initialize(); + isInitialized.set(true); + // Initialize player event listener for push-based updates await initPlayerEvents(); diff --git a/src/routes/library/+layout.svelte b/src/routes/library/+layout.svelte index 5d072ea..1abd99a 100644 --- a/src/routes/library/+layout.svelte +++ b/src/routes/library/+layout.svelte @@ -3,11 +3,11 @@ import { goto } from "$app/navigation"; import { page } from "$app/stores"; import { commands } from "$lib/api/bindings"; - import { platform } from "@tauri-apps/plugin-os"; import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth"; import { library } from "$lib/stores/library"; import { currentMedia, isPlaying, playbackPosition, playbackDuration, shouldShowAudioMiniPlayer } from "$lib/stores/player"; import { isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue"; + import { isAndroid } from "$lib/stores/appState"; import { useScrollGuard } from "$lib/composables/useScrollGuard"; import Search from "$lib/components/Search.svelte"; import MiniPlayer from "$lib/components/player/MiniPlayer.svelte"; @@ -31,17 +31,11 @@ const repeat = $derived($repeatMode); const hasNext = $derived($hasNextStore); const hasPrevious = $derived($hasPreviousStore); - let isAndroid = $state(false); + // Platform comes from the shared appState store (set once in the root layout), + // so this layout and the root layout never disagree about Android — otherwise + // both can suppress their mini players and none appears. See $lib/stores/appState. onMount(() => { - // Detect platform - try { - const platformName = platform(); - isAndroid = platformName === "android"; - } catch (err) { - console.error("Platform detection failed:", err); - } - return () => { scrollGuard.cleanup(); }; @@ -213,7 +207,7 @@
{@render children()} @@ -221,7 +215,7 @@ - {#if !isAndroid && !$page.url.pathname.startsWith('/player/')} + {#if !$isAndroid && !$page.url.pathname.startsWith('/player/')}