From 1836615dc0e28067da6a9be2e9e70207cc90a086 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 25 Jun 2026 19:18:06 +0200 Subject: [PATCH 1/4] feat(library): genre sliders, artist links, and navigation utils - music landing: diverse per-genre album sliders (online counts / offline wide-probe fallback) and home-screen library shortcuts - add ArtistLinks component and shared navigation/genreDiversity utils - player/playback-mode refinements across Rust and frontend --- .../jellytau/player/JellyTauPlayer.kt | 21 +++- src-tauri/src/commands/player/mod.rs | 6 +- src-tauri/src/lib.rs | 12 ++- src-tauri/src/playback_mode/mod.rs | 54 +++++++++-- src-tauri/src/player/android/mod.rs | 7 +- src-tauri/src/player/events.rs | 14 +-- src-tauri/src/player/mod.rs | 69 ++++++++++++- src-tauri/src/player/state.rs | 7 +- src-tauri/src/repository/offline.rs | 2 +- src-tauri/src/repository/online.rs | 28 +++++- src-tauri/src/repository/types.rs | 4 + src/lib/api/bindings.ts | 81 +++++++++++++++- src/lib/api/types.ts | 1 + .../library/AlphabetScrollBar.svelte | 37 +++++-- src/lib/components/library/ArtistLinks.svelte | 54 +++++++++++ .../components/library/ArtistLinks.test.ts | 57 +++++++++++ .../library/GenericGenreBrowser.svelte | 3 +- .../library/GenericMediaListPage.svelte | 3 +- src/lib/components/player/VideoPlayer.svelte | 21 ++-- src/lib/services/playerEvents.ts | 70 +++----------- src/lib/stores/appState.ts | 6 +- src/lib/stores/music.ts | 92 +++++++++++++++++- src/lib/stores/playbackMode.test.ts | 96 ++++++++++++++++++- src/lib/stores/playbackMode.ts | 14 +-- src/lib/stores/queue.ts | 13 ++- src/lib/stores/sessions.ts | 21 ++-- src/lib/utils/genreDiversity.test.ts | 79 +++++++++++++++ src/lib/utils/genreDiversity.ts | 95 ++++++++++++++++++ src/lib/utils/navigation.test.ts | 35 +++++++ src/lib/utils/navigation.ts | 33 +++++++ src/routes/+layout.svelte | 5 +- src/routes/+page.svelte | 50 +++++++++- src/routes/library/+layout.svelte | 46 ++------- src/routes/library/[id]/+page.svelte | 31 +++++- src/routes/library/movies/+page.svelte | 3 +- src/routes/library/music/+page.svelte | 14 ++- src/routes/library/tv/+page.svelte | 3 +- src/routes/player/[id]/+page.svelte | 29 ++---- 38 files changed, 1009 insertions(+), 207 deletions(-) create mode 100644 src/lib/components/library/ArtistLinks.svelte create mode 100644 src/lib/components/library/ArtistLinks.test.ts create mode 100644 src/lib/utils/genreDiversity.test.ts create mode 100644 src/lib/utils/genreDiversity.ts create mode 100644 src/lib/utils/navigation.test.ts create mode 100644 src/lib/utils/navigation.ts diff --git a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt index 9852686..cd8203b 100644 --- a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt +++ b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt @@ -138,6 +138,15 @@ class JellyTauPlayer(private val appContext: Context) { /** Current media ID being played */ private var currentMediaId: String? = null + /** + * Guards against nativeOnPlaybackEnded() firing more than once per loaded + * media. ExoPlayer can re-enter STATE_ENDED (e.g. transient buffering near + * end of a transcoded stream), which would otherwise notify the backend + * twice and, for example, decrement the sleep-timer episode counter twice. + * Reset whenever new media is loaded. + */ + private var endedNotified = false + /** Current media metadata for notification updates */ private var currentTitle: String = "" private var currentArtist: String = "" @@ -208,7 +217,15 @@ class JellyTauPlayer(private val appContext: Context) { // Playback completed android.util.Log.d("JellyTauPlayer", "▶ Playback ended") stopPositionUpdates() - nativeOnPlaybackEnded() + // Only notify the backend once per loaded media. ExoPlayer + // can re-enter STATE_ENDED, which would double-count things + // like the sleep-timer episode counter. + if (!endedNotified) { + endedNotified = true + nativeOnPlaybackEnded() + } else { + android.util.Log.d("JellyTauPlayer", "▶ Playback ended already notified - ignoring") + } } Player.STATE_BUFFERING -> { android.util.Log.d("JellyTauPlayer", "▶ Buffering...") @@ -322,6 +339,7 @@ class JellyTauPlayer(private val appContext: Context) { fun load(url: String, mediaId: String) { mainHandler.post { currentMediaId = mediaId + endedNotified = false val mediaItem = MediaItem.fromUri(url) exoPlayer.setMediaItem(mediaItem) exoPlayer.prepare() @@ -552,6 +570,7 @@ class JellyTauPlayer(private val appContext: Context) { ) { mainHandler.post { currentMediaId = mediaId + endedNotified = false // Store metadata for notification updates currentTitle = title diff --git a/src-tauri/src/commands/player/mod.rs b/src-tauri/src/commands/player/mod.rs index 300cbb4..31bf6e0 100644 --- a/src-tauri/src/commands/player/mod.rs +++ b/src-tauri/src/commands/player/mod.rs @@ -224,6 +224,10 @@ pub struct PlayTracksRequest { pub start_index: usize, pub shuffle: bool, pub context: PlayTracksContext, + /// Position (seconds) to resume the starting track from. Used when taking + /// over playback from a remote session so we don't restart from 0. + #[serde(default)] + pub start_position: Option, } /// Context information for track playback @@ -1531,7 +1535,7 @@ pub async fn player_play_tracks( // Play queue let controller = player.0.lock().await; - controller.play_queue(media_items, request.start_index) + controller.play_queue_from(media_items, request.start_index, request.start_position) .map_err(|e| e.to_string())?; // Set queue context diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f46329b..e5e405e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -356,6 +356,9 @@ fn specta_builder() -> Builder { // Throw on error so generated `commands.*` return Promise and throw, // matching the existing frontend's invoke() try/catch convention. .error_handling(tauri_specta::ErrorHandlingMode::Throw) + .events(tauri_specta::collect_events![ + crate::player::events::PlayerStatusEvent + ]) .commands(tauri_specta::collect_commands![ // Player commands player_play_item, @@ -601,11 +604,17 @@ pub fn run() { // `.export()` here would try to write `../src/lib/api/bindings.ts` at app // startup, which panics on devices (e.g. Android) where that path doesn't exist. let builder = specta_builder(); + let invoke_handler = builder.invoke_handler(); tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_os::init()) - .setup(|app| { + .invoke_handler(invoke_handler) + .setup(move |app| { + // Mount tauri-specta events so PlayerStatusEvent can be emitted to and + // listened for on the frontend via the generated bindings. + builder.mount_events(app); + // Initialize database with proper app data directory // Check for test mode environment variable first let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") { @@ -845,7 +854,6 @@ pub fn run() { info!("[INIT] Application setup completed successfully"); Ok(()) }) - .invoke_handler(builder.invoke_handler()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/playback_mode/mod.rs b/src-tauri/src/playback_mode/mod.rs index b592923..dc3acea 100644 --- a/src-tauri/src/playback_mode/mod.rs +++ b/src-tauri/src/playback_mode/mod.rs @@ -20,6 +20,28 @@ pub enum PlaybackMode { Idle, } +/// Number of Jellyfin ticks per second (100ns units). +const TICKS_PER_SECOND: f64 = 10_000_000.0; + +/// Below this many seconds we treat the position as "at the start" and don't +/// send a resume position, so a fresh track casts from 0 rather than ~0. +const RESUME_THRESHOLD_SECONDS: f64 = 0.5; + +/// Convert a live playback position (seconds) into the `StartPositionTicks` to +/// hand to a remote session, or `None` if we're effectively at the start. +/// +/// Pure helper so the resume-position math is unit-testable without a remote +/// session or HTTP. The *source* of `position_seconds` matters too: callers +/// must pass the live backend position (`PlayerController::position()`), not the +/// snapshot embedded in `PlayerState`, which is stale mid-track on Android. +fn start_position_ticks_from_seconds(position_seconds: f64) -> Option { + if position_seconds > RESUME_THRESHOLD_SECONDS { + Some((position_seconds * TICKS_PER_SECOND) as i64) + } else { + None + } +} + /// Manages playback mode transfers between local and remote sessions pub struct PlaybackModeManager { jellyfin_client: Arc>>, @@ -195,7 +217,6 @@ impl PlaybackModeManager { let queue_arc = player.queue(); let queue = queue_arc.lock_safe(); - let state = player.state(); let original_index = queue.current_index().unwrap_or(0); let items = queue.items(); @@ -210,7 +231,11 @@ impl PlaybackModeManager { } let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?; - let position = state.position().unwrap_or(0.0); + // Read the LIVE backend position, not the snapshot embedded in PlayerState. + // On Android the embedded position is only refreshed on play/pause + // transitions, so PlayerState::position() is stale (often 0) mid-track; + // PlayerController::position() always reflects the backend's current time. + let position = player.position(); let context = queue.context().clone(); log::info!( @@ -272,12 +297,8 @@ impl PlaybackModeManager { } }; - // Calculate position in ticks - let start_position_ticks = if position_seconds > 0.5 { - Some((position_seconds * 10_000_000.0) as i64) - } else { - None - }; + // Calculate position in ticks (from the live position read above) + let start_position_ticks = start_position_ticks_from_seconds(position_seconds); // Log queue context for debugging (context is tracked but we always send track IDs) match &queue_context { @@ -577,6 +598,23 @@ mod tests { ); } + /// The resume position handed to a remote session is derived from a live + /// playback position. Guards the seconds->ticks conversion and the + /// at-the-start threshold (Bug: casting restarted the track from 0). + #[test] + fn test_start_position_ticks_from_seconds() { + // Mid-track positions convert to ticks (10M ticks per second). + assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000)); + assert_eq!(start_position_ticks_from_seconds(123.45), Some(1_234_500_000)); + + // At/near the start, send no resume position so the track casts from 0. + assert_eq!(start_position_ticks_from_seconds(0.0), None); + assert_eq!(start_position_ticks_from_seconds(0.5), None); + + // Just past the threshold resumes rather than restarting. + assert!(start_position_ticks_from_seconds(0.6).is_some()); + } + // Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID mod extract_jellyfin_ids_tests { use crate::player::{MediaItem, MediaSource, MediaType}; diff --git a/src-tauri/src/player/android/mod.rs b/src-tauri/src/player/android/mod.rs index 9e9b1d0..f082755 100644 --- a/src-tauri/src/player/android/mod.rs +++ b/src-tauri/src/player/android/mod.rs @@ -678,7 +678,12 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO // Use tauri::async_runtime::spawn instead of tokio::spawn // JNI callbacks happen on arbitrary threads without a Tokio runtime tauri::async_runtime::spawn(async move { - match controller.lock().await.on_playback_ended().await { + // Compute the autoplay decision and release the lock before matching. + // Holding the guard across the match would deadlock the AdvanceToNext + // arm, which re-locks the controller to call next() — leaving playback + // stopped (paused at position 0) instead of advancing. + let decision = controller.lock().await.on_playback_ended().await; + match decision { Ok(AutoplayDecision::Stop) => { log::debug!("[Autoplay] Decision: Stop playback"); // Emit PlaybackEnded event to frontend diff --git a/src-tauri/src/player/events.rs b/src-tauri/src/player/events.rs index 9ee8c80..70ab5e9 100644 --- a/src-tauri/src/player/events.rs +++ b/src-tauri/src/player/events.rs @@ -10,7 +10,8 @@ use crate::utils::lock::MutexSafe; use log::error; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use tauri::{AppHandle, Emitter}; +use tauri::AppHandle; +use tauri_specta::Event; use super::{MediaSessionType, SleepTimerMode}; @@ -20,8 +21,8 @@ use super::{MediaSessionType, SleepTimerMode}; /// state machine transitions. /// /// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)] +#[serde(tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase")] pub enum PlayerStatusEvent { /// Playback position updated (emitted periodically during playback) PositionUpdate { @@ -113,9 +114,6 @@ pub enum PlayerStatusEvent { }, } -/// Tauri event name for player status events -pub const PLAYER_EVENT_NAME: &str = "player-event"; - /// Trait for emitting player events to the frontend. /// /// This abstraction allows backends to emit events without depending @@ -141,7 +139,9 @@ impl TauriEventEmitter { impl PlayerEventEmitter for TauriEventEmitter { fn emit(&self, event: PlayerStatusEvent) { - if let Err(e) = self.app_handle.emit(PLAYER_EVENT_NAME, &event) { + // Emitted via the tauri-specta Event trait so the payload shape and event + // name match the generated TypeScript bindings (events.playerStatusEvent). + if let Err(e) = Event::emit(&event, &self.app_handle) { error!("Failed to emit player event: {}", e); } } diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 6a24990..13010f1 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -311,7 +311,27 @@ impl PlayerController { /// Set the queue and start playing from the specified index pub fn play_queue(&self, items: Vec, start_index: usize) -> Result<(), PlayerError> { - debug!("[PlayerController] play_queue: {} items, starting at index {}", items.len(), start_index); + self.play_queue_from(items, start_index, None) + } + + /// Set the queue and start playing from the specified index, optionally + /// resuming the starting track at `start_position` (seconds). + /// + /// The seek happens immediately after load so the backend never audibly + /// starts at 0 and there's no race against a fixed delay. Used when taking + /// over playback from a remote session. + pub fn play_queue_from( + &self, + items: Vec, + start_index: usize, + start_position: Option, + ) -> Result<(), PlayerError> { + debug!( + "[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})", + items.len(), + start_index, + start_position + ); // Reset autoplay counter on manual queue start self.reset_autoplay_count(); @@ -324,6 +344,15 @@ impl PlayerController { // Play the current item (without modifying the queue we just set) if let Some(item) = self.queue.lock_safe().current().cloned() { self.load_and_play(&item)?; + + // Resume from the requested position. Seeking right after load (while + // the backend lock is no longer held) avoids the start-at-0-then-jump + // race that a delayed frontend seek suffers from. + if let Some(position) = start_position { + if position > 0.5 { + self.seek(position)?; + } + } } Ok(()) @@ -1333,6 +1362,44 @@ mod tests { } } + /// Resuming a queue at a position seeks the starting track immediately. + /// Regression guard for taking over a remote session: the local player must + /// pick up where the remote left off, not restart from 0. + #[test] + fn test_play_queue_from_resumes_at_position() { + let controller = PlayerController::default(); + let items = create_test_items(3); + + controller.play_queue_from(items, 1, Some(42.5)).unwrap(); + + { + let queue = controller.queue(); + let queue_lock = queue.lock_safe(); + assert_eq!(queue_lock.current_index(), Some(1), "Should start at index 1"); + } + assert_eq!(controller.position(), 42.5, "Should resume at the requested position"); + } + + /// A None / near-zero start position starts the track from the beginning. + #[test] + fn test_play_queue_from_without_position_starts_at_zero() { + let controller = PlayerController::default(); + + controller + .play_queue_from(create_test_items(2), 0, None) + .unwrap(); + assert_eq!(controller.position(), 0.0, "No resume position starts at 0"); + + controller + .play_queue_from(create_test_items(2), 0, Some(0.2)) + .unwrap(); + assert_eq!( + controller.position(), + 0.0, + "Sub-threshold resume position is ignored (starts at 0)" + ); + } + #[test] fn test_seek_to_zero() { let controller = PlayerController::default(); diff --git a/src-tauri/src/player/state.rs b/src-tauri/src/player/state.rs index da13952..7faf2e6 100644 --- a/src-tauri/src/player/state.rs +++ b/src-tauri/src/player/state.rs @@ -61,7 +61,12 @@ pub enum PlayerState { } impl PlayerState { - /// Get the current playback position if available + /// Get the current playback position if available. + /// + /// Note: this is the position snapshot embedded in the state at the last + /// state transition, not the live backend position. For an up-to-date + /// value use `PlayerController::position()`. + #[allow(dead_code)] pub fn position(&self) -> Option { match self { PlayerState::Playing { position, .. } => Some(*position), diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index b1d6594..dfce39a 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -864,7 +864,7 @@ impl MediaRepository for OfflineRepository { let genres = genre_set .into_iter() - .map(|name| Genre { id: name.clone(), name }) + .map(|name| Genre { id: name.clone(), name, album_count: None }) .collect(); Ok(genres) diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index 2bbe6e3..9d2c356 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -811,7 +811,12 @@ impl MediaRepository for OnlineRepository { } async fn get_genres(&self, parent_id: Option<&str>) -> Result, RepoError> { - let mut endpoint = format!("/Genres?UserId={}", self.user_id); + // Ask Jellyfin to scope counts to albums and include them, so the + // frontend can rank genres by popularity without probing each one. + let mut endpoint = format!( + "/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts", + self.user_id + ); if let Some(pid) = parent_id { endpoint.push_str(&format!("&ParentId={}", pid)); @@ -828,17 +833,34 @@ impl MediaRepository for OnlineRepository { struct JellyfinGenre { id: String, name: String, + // Which count field Jellyfin populates for a genre under + // Fields=ItemCounts varies by server/version: scoped queries may + // fill AlbumCount, others only ChildCount. Read whichever is + // present so ranking still works. Absent on servers that ignore + // Fields=ItemCounts entirely, so all stay optional. + album_count: Option, + child_count: Option, } let response: GenresResponse = self.get_json(&endpoint).await?; - Ok(response + let genres: Vec = response .items .into_iter() .map(|g| Genre { id: g.id, name: g.name, + album_count: g.album_count.or(g.child_count), }) - .collect()) + .collect(); + + let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count(); + log::debug!( + "get_genres: {} genres, {} carry counts (AlbumCount/ChildCount)", + genres.len(), + with_counts + ); + + Ok(genres) } async fn search( diff --git a/src-tauri/src/repository/types.rs b/src-tauri/src/repository/types.rs index 3f9a5f6..e3c4998 100644 --- a/src-tauri/src/repository/types.rs +++ b/src-tauri/src/repository/types.rs @@ -255,6 +255,10 @@ pub struct PlaybackInfo { pub struct Genre { pub id: String, pub name: String, + /// Number of albums tagged with this genre, when the backend can supply it + /// (online only). Lets the frontend rank/pick genres without probing each + /// one. `None` when unknown (e.g. offline). + pub album_count: Option, } /// Image type diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index 17a3457..d2c68c5 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -1276,6 +1276,11 @@ async convertPercentToVolume(percent: number) : Promise { /** user-defined events **/ +export const events = __makeEvents__<{ +playerStatusEvent: PlayerStatusEvent +}>({ +playerStatusEvent: "player-status-event" +}) /** user-defined constants **/ @@ -1471,7 +1476,13 @@ export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStat /** * Genre */ -export type Genre = { id: string; name: string } +export type Genre = { id: string; name: string; +/** + * Number of albums tagged with this genre, when the backend can supply it + * (online only). Lets the frontend rank/pick genres without probing each + * one. `None` when unknown (e.g. offline). + */ +albumCount: number | null } /** * Request to get an image URL (with caching) */ @@ -1610,7 +1621,12 @@ export type PlayTracksContext = { type: "playlist"; playlistId: string; playlist /** * Request to play tracks by ID (backend fetches metadata) */ -export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffle: boolean; context: PlayTracksContext } +export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffle: boolean; context: PlayTracksContext; +/** + * Position (seconds) to resume the starting track from. Used when taking + * over playback from a remote session so we don't restart from 0. + */ +startPosition?: number | null } /** * Playback information */ @@ -1791,6 +1807,67 @@ mergedIsPlaying: boolean; * Volume from either local player or remote session (0-1 normalized) */ mergedVolume: number } +/** + * Events emitted by the player backend to the frontend via Tauri events. + * + * These are distinct from `PlayerEvent` in state.rs, which handles internal + * state machine transitions. + * + * TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047 + */ +export type PlayerStatusEvent = +/** + * Playback position updated (emitted periodically during playback) + */ +{ type: "position_update"; position: number; duration: number } | +/** + * Player state changed + */ +{ type: "state_changed"; state: string; media_id: string | null } | +/** + * Media has finished loading and is ready to play + */ +{ type: "media_loaded"; duration: number } | +/** + * Playback has ended naturally (reached end of media) + */ +{ type: "playback_ended" } | +/** + * Buffering state changed + */ +{ type: "buffering"; percent: number } | +/** + * An error occurred during playback + */ +{ type: "error"; message: string; recoverable: boolean } | +/** + * Volume changed + */ +{ type: "volume_changed"; volume: number; muted: boolean } | +/** + * Sleep timer state changed + */ +{ type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } | +/** + * Show next episode popup with countdown + */ +{ type: "show_next_episode_popup"; current_episode: MediaItem; next_episode: MediaItem; countdown_seconds: number; auto_advance: boolean } | +/** + * Countdown tick (emitted every second during autoplay countdown) + */ +{ type: "countdown_tick"; remaining_seconds: number } | +/** + * Queue changed (items added, removed, reordered, or playback mode changed) + */ +{ type: "queue_changed"; items: PlayerMediaItem[]; current_index: number | null; shuffle: boolean; repeat: RepeatMode; has_next: boolean; has_previous: boolean } | +/** + * Media session changed (activity context changed: Audio/Movie/TvShow/Idle) + */ +{ type: "session_changed"; session: MediaSessionType } | +/** + * Remote sessions updated (for cast/remote control UI) + */ +{ type: "sessions_updated"; sessions: SessionInfo[] } /** * Result of creating a playlist * diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index e496f7a..b9f848f 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -5,6 +5,7 @@ // and adds frontend-only unions/helpers that have no backend equivalent. export type { + ArtistItem, AuthResult, Genre, GetItemsOptions, diff --git a/src/lib/components/library/AlphabetScrollBar.svelte b/src/lib/components/library/AlphabetScrollBar.svelte index 6c7e297..31be3ce 100644 --- a/src/lib/components/library/AlphabetScrollBar.svelte +++ b/src/lib/components/library/AlphabetScrollBar.svelte @@ -26,17 +26,28 @@ let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props(); - // The strip stretches to fill the height of its scroll container (the page's - //
, which sits below the sticky header) from the sticky top offset down - // to the bottom gap reserved for the nav / mini-player bars. We measure the - // container's visible height live so it stays correct regardless of header - // size, platform, or window resizes. + // The strip stretches from where it sits down to just above the bottom nav / + // mini-player bars. Those bars are pinned to the bottom of the screen, so the + // hard floor is `window.innerHeight - bottomGap`. We measure the strip's own + // top against that floor (clamped to non-negative) and update on scroll/resize + // so it never slides under the bars regardless of header or platform. let container = $state(null); - let viewportHeight = $state(0); + let stripHeight = $state(0); function measure() { - const scroller = container?.closest("main") ?? document.documentElement; - viewportHeight = scroller.clientHeight; + if (!container) return; + const top = container.getBoundingClientRect().top; + const floor = window.innerHeight - remToPx(bottomGap); + stripHeight = Math.max(0, floor - top); + } + + function remToPx(len: string): number { + const n = parseFloat(len); + if (len.trim().endsWith("rem")) { + const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + return n * root; + } + return n; // assume px otherwise } $effect(() => { @@ -44,13 +55,21 @@ const scroller = container?.closest("main"); const ro = new ResizeObserver(measure); if (scroller) ro.observe(scroller); + scroller?.addEventListener("scroll", measure, { passive: true }); window.addEventListener("resize", measure); return () => { ro.disconnect(); + scroller?.removeEventListener("scroll", measure); window.removeEventListener("resize", measure); }; }); + // Recompute when the reserved bottom gap changes (mini-player shows/hides). + $effect(() => { + bottomGap; + measure(); + }); + const letters = $derived([HASH, ...ALPHABET]); let activeLetter = $state(null); @@ -99,7 +118,7 @@