fix: several small fixes
This commit is contained in:
parent
1836615dc0
commit
2811e1b7ca
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -830,41 +830,57 @@ impl MediaRepository for OfflineRepository {
|
||||
}
|
||||
|
||||
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, 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<String> = self.db_service.query_many(query, |row| row.get(0))
|
||||
let genres_rows: Vec<String> = 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<String, u32> = std::collections::HashMap::new();
|
||||
for genres_json in genres_rows {
|
||||
if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&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)
|
||||
|
||||
@ -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::<Vec<_>>()
|
||||
);
|
||||
|
||||
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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 @@
|
||||
<!-- Main content (with padding for bottom nav bar and mini player) -->
|
||||
<main
|
||||
class="flex-1 overflow-y-auto p-4"
|
||||
style="padding-bottom: {$shouldShowAudioMiniPlayer ? (isAndroid ? '11rem' : '7rem') : '5rem'}; overscroll-behavior: contain"
|
||||
style="padding-bottom: {$shouldShowAudioMiniPlayer ? ($isAndroid ? '11rem' : '7rem') : '5rem'}; overscroll-behavior: contain"
|
||||
onscroll={scrollGuard.onScroll}
|
||||
>
|
||||
{@render children()}
|
||||
@ -221,7 +215,7 @@
|
||||
|
||||
<!-- Mini Player (only show on non-Android platforms - Android uses global mini player) -->
|
||||
<!-- Hide on player page since full player is already there -->
|
||||
{#if !isAndroid && !$page.url.pathname.startsWith('/player/')}
|
||||
{#if !$isAndroid && !$page.url.pathname.startsWith('/player/')}
|
||||
<MiniPlayer
|
||||
media={$currentMedia}
|
||||
isPlaying={$isPlaying}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user