From c5be9eb18c9f74453123a36078b913d5b89cc065 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 28 Feb 2026 20:33:22 +0100 Subject: [PATCH] improvements to the sleep timer --- README.md | 16 +-- src-tauri/src/commands/player.rs | 25 +++- src-tauri/src/jellyfin/http_client.rs | 4 +- src-tauri/src/player/autoplay.rs | 25 ++++ src-tauri/src/player/mod.rs | 82 +++++++++++- src-tauri/src/repository/online.rs | 22 ++-- src-tauri/src/settings.rs | 18 +++ src-tauri/src/storage/schema.rs | 9 ++ src/lib/api/autoplay.ts | 1 + src/lib/components/common/ScrollPicker.svelte | 117 ++++++++++++++++++ .../library/GenericMediaListPage.svelte | 8 +- src/lib/components/player/AudioPlayer.svelte | 3 +- src/lib/components/player/Controls.svelte | 25 +++- src/lib/components/player/MiniPlayer.svelte | 23 +++- .../components/player/SleepTimerModal.svelte | 54 ++++---- src/lib/components/player/VideoPlayer.svelte | 34 ++++- src/routes/library/+layout.svelte | 2 +- src/routes/player/[id]/+page.svelte | 28 +++++ src/routes/settings/+page.svelte | 36 +++++- 19 files changed, 475 insertions(+), 57 deletions(-) create mode 100644 src/lib/components/common/ScrollPicker.svelte diff --git a/README.md b/README.md index 9b06b16..ef60285 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,10 @@ A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript. | UR-020 | Select subtitles for video content | High | Planned | | UR-021 | Select audio track for video content | High | Planned | | UR-022 | Control streaming quality and transcoding settings | Medium | Planned | -| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup | Medium | Done | +| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup and configurable episode limit | Medium | Done | | UR-024 | View recently added content on server | Medium | Done | | UR-025 | Sync watch history and progress back to Jellyfin | High | Done | -| UR-026 | Sleep timer for audio playback | Low | Done | +| UR-026 | Sleep timer for audio and video playback (roller UI, time/track/episode modes) | Low | Done | | UR-027 | Audio equalizer for sound customization | Low | Planned | | UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done | | UR-029 | Toggle between grid and list view in library | Medium | Done | @@ -161,7 +161,9 @@ Internal architecture, components, and application logic. | DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done | | DR-027 | "Recently Added" home section | UI | UR-024 | Done | | DR-028 | Playback progress sync service (periodic reporting) | Player | UR-025 | Done | -| DR-029 | Sleep timer with countdown and auto-stop | Player | UR-026 | Done | +| DR-029 | Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players) | Player | UR-026 | Done | +| DR-049 | Auto-play episode limit (configurable max episodes per session) | Player | UR-023 | Done | +| DR-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done | | DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned | | DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done | | DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done | @@ -179,8 +181,8 @@ Internal architecture, components, and application logic. | DR-044 | Horizontal scrolling actor/cast row with profile images | UI | UR-035 | Done | | DR-045 | Bottom navigation bar with Home, Library, Search buttons | UI | UR-039 | Done | | DR-046 | Dedicated search page with input and results | UI | UR-039 | Done | -| DR-047 | Next episode auto-play popup with configurable countdown | Player | UR-023 | Done | -| DR-048 | Video settings (auto-play toggle, countdown duration) | Settings | UR-023, UR-026 | Done | +| DR-047 | Next episode auto-play popup with configurable countdown and episode limit | Player | UR-023 | Done | +| DR-048 | Video settings (auto-play toggle, countdown duration, episode limit) | Settings | UR-023, UR-026 | Done | --- @@ -212,10 +214,10 @@ Internal architecture, components, and application logic. | UR-020 | IR-016, IR-018 | DR-023 | | UR-021 | IR-016, IR-019 | DR-024 | | UR-022 | IR-017 | DR-025 | -| UR-023 | IR-010 | DR-026, DR-047, DR-048 | +| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 | | UR-024 | IR-010 | DR-027 | | UR-025 | IR-015 | DR-028 | -| UR-026 | - | DR-029, DR-048 | +| UR-026 | - | DR-029, DR-048, DR-050 | | UR-027 | IR-020 | DR-030 | | UR-028 | - | DR-031 | | UR-029 | - | DR-032 | diff --git a/src-tauri/src/commands/player.rs b/src-tauri/src/commands/player.rs index 348931d..a83dc94 100644 --- a/src-tauri/src/commands/player.rs +++ b/src-tauri/src/commands/player.rs @@ -1231,11 +1231,24 @@ pub async fn player_get_audio_settings( #[tauri::command] pub async fn player_set_video_settings( video_settings: State<'_, VideoSettingsWrapper>, + player: State<'_, PlayerStateWrapper>, settings: VideoSettings, ) -> Result { - let mut current = video_settings.0.lock().map_err(|e| e.to_string())?; - *current = settings.with_countdown_clamped(); - Ok(current.clone()) + let validated = settings.with_countdown_clamped(); + { + let mut current = video_settings.0.lock().map_err(|e| e.to_string())?; + *current = validated.clone(); + } // Drop MutexGuard before await + + // Sync to PlayerController's autoplay settings so on_playback_ended() uses current values + let controller = player.0.lock().await; + controller.set_autoplay_settings(AutoplaySettings { + enabled: validated.auto_play_next_episode, + countdown_seconds: validated.auto_play_countdown_seconds, + max_episodes: validated.auto_play_max_episodes, + }); + + Ok(validated) } #[tauri::command] @@ -2333,16 +2346,18 @@ pub async fn player_set_autoplay_settings( }; let query = Query::with_params( - "INSERT INTO user_player_settings (user_id, autoplay_next_episode, autoplay_countdown_seconds, updated_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP) + "INSERT INTO user_player_settings (user_id, autoplay_next_episode, autoplay_countdown_seconds, autoplay_max_episodes, updated_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET autoplay_next_episode = excluded.autoplay_next_episode, autoplay_countdown_seconds = excluded.autoplay_countdown_seconds, + autoplay_max_episodes = excluded.autoplay_max_episodes, updated_at = CURRENT_TIMESTAMP", vec![ QueryParam::String(user_id), QueryParam::Int(if validated.enabled { 1 } else { 0 }), QueryParam::Int(validated.countdown_seconds as i32), + QueryParam::Int(validated.max_episodes as i32), ], ); diff --git a/src-tauri/src/jellyfin/http_client.rs b/src-tauri/src/jellyfin/http_client.rs index 141ab37..bb076fa 100644 --- a/src-tauri/src/jellyfin/http_client.rs +++ b/src-tauri/src/jellyfin/http_client.rs @@ -5,8 +5,8 @@ use std::time::Duration; const APP_NAME: &str = "JellyTau"; const APP_VERSION: &str = "0.1.0"; -// Default timeout for requests (10 seconds) -const DEFAULT_TIMEOUT_MS: u64 = 10000; +// Default timeout for requests (30 seconds - large library queries can be slow) +const DEFAULT_TIMEOUT_MS: u64 = 30000; // Retry configuration - matches TypeScript exactly const DEFAULT_MAX_RETRIES: u32 = 3; diff --git a/src-tauri/src/player/autoplay.rs b/src-tauri/src/player/autoplay.rs index d90bfb8..c3d48b1 100644 --- a/src-tauri/src/player/autoplay.rs +++ b/src-tauri/src/player/autoplay.rs @@ -28,6 +28,9 @@ pub struct AutoplaySettings { pub enabled: bool, /// Countdown duration in seconds before auto-playing next episode pub countdown_seconds: u32, + /// Maximum number of episodes to auto-play consecutively (0 = unlimited) + #[serde(default)] + pub max_episodes: u32, } impl Default for AutoplaySettings { @@ -35,6 +38,7 @@ impl Default for AutoplaySettings { Self { enabled: true, countdown_seconds: 10, + max_episodes: 0, } } } @@ -56,6 +60,24 @@ mod tests { let settings = AutoplaySettings::default(); assert!(settings.enabled); assert_eq!(settings.countdown_seconds, 10); + assert_eq!(settings.max_episodes, 0); + } + + #[test] + fn test_autoplay_settings_backward_compat() { + // Deserialize old JSON without max_episodes field + let json = r#"{"enabled":true,"countdownSeconds":15}"#; + let settings: AutoplaySettings = serde_json::from_str(json).unwrap(); + assert!(settings.enabled); + assert_eq!(settings.countdown_seconds, 15); + assert_eq!(settings.max_episodes, 0); // defaults to 0 (unlimited) + } + + #[test] + fn test_autoplay_settings_with_max_episodes() { + let json = r#"{"enabled":true,"countdownSeconds":10,"maxEpisodes":5}"#; + let settings: AutoplaySettings = serde_json::from_str(json).unwrap(); + assert_eq!(settings.max_episodes, 5); } #[test] @@ -63,6 +85,7 @@ mod tests { let settings = AutoplaySettings { enabled: true, countdown_seconds: 2, // Too short + max_episodes: 0, } .with_validated_countdown(); assert_eq!(settings.countdown_seconds, 5); // Clamped to min @@ -70,6 +93,7 @@ mod tests { let settings = AutoplaySettings { enabled: true, countdown_seconds: 60, // Too long + max_episodes: 0, } .with_validated_countdown(); assert_eq!(settings.countdown_seconds, 30); // Clamped to max @@ -77,6 +101,7 @@ mod tests { let settings = AutoplaySettings { enabled: true, countdown_seconds: 15, // Valid + max_episodes: 0, } .with_validated_countdown(); assert_eq!(settings.countdown_seconds, 15); // Unchanged diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 1bc1a45..5df6fd3 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -44,7 +44,7 @@ pub use android::{ set_media_command_handler, set_remote_volume_handler, get_detected_codecs, }; -use log::{debug, warn}; +use log::{debug, error, warn}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::Mutex as TokioMutex; @@ -86,6 +86,9 @@ pub struct PlayerController { // End reason tracking for autoplay decision making end_reason: Arc>>, + + // Auto-play episode counter (session-based, resets on manual play) + autoplay_episode_count: Arc>, } impl PlayerController { @@ -107,6 +110,7 @@ impl PlayerController { playback_reporter, position_throttler, end_reason: Arc::new(Mutex::new(None)), + autoplay_episode_count: Arc::new(Mutex::new(0)), }; // Start background timer thread for sleep timer countdown @@ -161,11 +165,38 @@ impl PlayerController { self.end_reason.lock().unwrap().take() } + /// Increment autoplay episode counter. Returns true if limit is reached. + fn increment_autoplay_count(&self) -> bool { + let max = self.autoplay_settings.lock().unwrap().max_episodes; + + if max == 0 { + // Unlimited + return false; + } + + let mut count = self.autoplay_episode_count.lock().unwrap(); + *count += 1; + debug!("[PlayerController] Autoplay episode count: {}/{}", *count, max); + + *count >= max + } + + /// Reset autoplay episode counter (called on manual play actions) + fn reset_autoplay_count(&self) { + let mut count = self.autoplay_episode_count.lock().unwrap(); + if *count > 0 { + debug!("[PlayerController] Resetting autoplay episode counter (was {})", *count); + } + *count = 0; + } /// Load and play a single item (also sets the queue to contain only this item) pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> { debug!("[PlayerController] play_item: {}", item.title); + // Reset autoplay counter on manual play + self.reset_autoplay_count(); + // Update queue with this single item { let mut queue = self.queue.lock().unwrap(); @@ -262,6 +293,9 @@ impl PlayerController { pub fn play_queue(&self, items: Vec, start_index: usize) -> Result<(), PlayerError> { debug!("[PlayerController] play_queue: {} items, starting at index {}", items.len(), start_index); + // Reset autoplay counter on manual queue start + self.reset_autoplay_count(); + { let mut queue = self.queue.lock().unwrap(); queue.set_queue(items, start_index); @@ -374,6 +408,9 @@ impl PlayerController { /// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay /// from triggering when the current track's EndFile event fires pub fn next(&self) -> Result<(), PlayerError> { + // Reset autoplay counter on manual skip + self.reset_autoplay_count(); + let next_item = { let mut queue = self.queue.lock().unwrap(); queue.next().cloned() @@ -394,6 +431,8 @@ impl PlayerController { /// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay /// from triggering when the current track's EndFile event fires pub fn previous(&self) -> Result<(), PlayerError> { + // Reset autoplay counter on manual skip + self.reset_autoplay_count(); // If we're more than 3 seconds in, restart current track { let backend = self.backend.lock().unwrap(); @@ -544,6 +583,7 @@ impl PlayerController { fn start_timer_thread(&self) { let sleep_timer = self.sleep_timer.clone(); let event_emitter = self.event_emitter.clone(); + let backend = self.backend.clone(); std::thread::spawn(move || { loop { @@ -553,6 +593,27 @@ impl PlayerController { if timer.is_active() { timer.update_remaining_seconds(); + // Time-based timer expired: stop playback + if matches!(timer.mode, SleepTimerMode::Time { .. }) && timer.remaining_seconds == 0 { + debug!("[SleepTimer] Time-based timer expired, stopping playback"); + timer.cancel(); + + // Emit cancelled state + if let Some(emitter) = event_emitter.lock().unwrap().as_ref() { + emitter.emit(PlayerStatusEvent::SleepTimerChanged { + mode: SleepTimerMode::Off, + remaining_seconds: 0, + }); + } + drop(timer); + + // Stop the backend + if let Err(e) = backend.lock().unwrap().stop() { + error!("[SleepTimer] Failed to stop playback: {}", e); + } + continue; + } + // Emit update event if let Some(emitter) = event_emitter.lock().unwrap().as_ref() { emitter.emit(PlayerStatusEvent::SleepTimerChanged { @@ -673,6 +734,16 @@ impl PlayerController { }; match &timer_mode { + SleepTimerMode::Time { end_time } => { + // If time has expired, stop instead of playing next + let now = chrono::Utc::now().timestamp_millis(); + if now >= *end_time { + debug!("[PlayerController] Time-based sleep timer expired at track boundary"); + self.sleep_timer.lock().unwrap().cancel(); + self.emit_sleep_timer_changed(); + return Ok(AutoplayDecision::Stop); + } + } SleepTimerMode::EndOfTrack => { // Stop at end of track self.sleep_timer.lock().unwrap().cancel(); @@ -702,11 +773,18 @@ impl PlayerController { if current.media_type == MediaType::Video && self.is_episode_item(¤t).await { if let Some(next_ep) = self.fetch_next_episode_for_item(¤t).await? { let settings = self.autoplay_settings.lock().unwrap().clone(); + + // Check if auto-play episode limit is reached + let limit_reached = self.increment_autoplay_count(); + if limit_reached { + debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes); + } + return Ok(AutoplayDecision::ShowNextEpisodePopup { current_episode: next_ep.0, // Repository MediaItem next_episode: next_ep.1, countdown_seconds: settings.countdown_seconds, - auto_advance: settings.enabled, + auto_advance: settings.enabled && !limit_reached, }); } // No next episode found diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index 7a5e7ef..b66b920 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -469,8 +469,8 @@ impl MediaRepository for OnlineRepository { } } - // Always request backdrop image fields - endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,People"); + // Request image fields for list views (People only needed in get_item detail view) + endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags"); let response: ItemsResponse = self.get_json(&endpoint).await?; @@ -500,7 +500,7 @@ impl MediaRepository for OnlineRepository { ) -> Result, RepoError> { let limit_str = limit.unwrap_or(16); let endpoint = format!( - "/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,People", + "/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags", self.user_id, parent_id, limit_str ); @@ -518,7 +518,7 @@ impl MediaRepository for OnlineRepository { ) -> Result, RepoError> { let limit_str = limit.unwrap_or(16); let mut endpoint = format!( - "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video,Audio&Fields=BackdropImageTags,ParentBackdropImageTags,People", + "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video,Audio&Fields=BackdropImageTags,ParentBackdropImageTags", self.user_id, limit_str ); @@ -540,7 +540,7 @@ impl MediaRepository for OnlineRepository { limit: Option, ) -> Result, RepoError> { let limit_str = limit.unwrap_or(16); - let mut endpoint = format!("/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,People", self.user_id, limit_str); + let mut endpoint = format!("/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags", self.user_id, limit_str); if let Some(sid) = series_id { endpoint.push_str(&format!("&SeriesId={}", sid)); @@ -562,7 +562,7 @@ impl MediaRepository for OnlineRepository { // Fetch more items to account for grouping reducing the count let fetch_limit = limit_val * 3; let endpoint = format!( - "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,People", + "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags", self.user_id, fetch_limit ); @@ -661,7 +661,7 @@ impl MediaRepository for OnlineRepository { async fn get_resume_movies(&self, limit: Option) -> Result, RepoError> { let limit_str = limit.unwrap_or(16); let endpoint = format!( - "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,People", + "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags", self.user_id, limit_str ); @@ -721,8 +721,8 @@ impl MediaRepository for OnlineRepository { } } - // Always request backdrop image fields - endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,People"); + // Request image fields for list views (People only needed in get_item detail view) + endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags"); let response: ItemsResponse = self.get_json(&endpoint).await?; Ok(SearchResult { @@ -1142,7 +1142,7 @@ impl MediaRepository for OnlineRepository { let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100); let mut endpoint = format!( - "/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,People", + "/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags", self.user_id, person_id, limit ); @@ -1176,7 +1176,7 @@ impl MediaRepository for OnlineRepository { // Try the /Similar endpoint which works for most items let endpoint = format!( - "/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,People", + "/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags", item_id, self.user_id, limit_str ); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 757a166..b3088a0 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -65,6 +65,9 @@ pub struct VideoSettings { pub auto_play_next_episode: bool, /// Countdown duration in seconds before auto-play (5-30 seconds) pub auto_play_countdown_seconds: u32, + /// Maximum number of episodes to auto-play consecutively (0 = unlimited) + #[serde(default)] + pub auto_play_max_episodes: u32, } impl Default for VideoSettings { @@ -72,6 +75,7 @@ impl Default for VideoSettings { Self { auto_play_next_episode: true, auto_play_countdown_seconds: 10, + auto_play_max_episodes: 0, } } } @@ -146,6 +150,7 @@ mod tests { let settings = VideoSettings::default(); assert!(settings.auto_play_next_episode); assert_eq!(settings.auto_play_countdown_seconds, 10); + assert_eq!(settings.auto_play_max_episodes, 0); } #[test] @@ -170,14 +175,27 @@ mod tests { let settings = VideoSettings { auto_play_next_episode: false, auto_play_countdown_seconds: 15, + auto_play_max_episodes: 5, }; let json = serde_json::to_string(&settings).unwrap(); assert!(json.contains("\"autoPlayNextEpisode\":false")); assert!(json.contains("\"autoPlayCountdownSeconds\":15")); + assert!(json.contains("\"autoPlayMaxEpisodes\":5")); let parsed: VideoSettings = serde_json::from_str(&json).unwrap(); assert!(!parsed.auto_play_next_episode); assert_eq!(parsed.auto_play_countdown_seconds, 15); + assert_eq!(parsed.auto_play_max_episodes, 5); + } + + #[test] + fn test_video_settings_backward_compat() { + // Old JSON without auto_play_max_episodes field + let json = r#"{"autoPlayNextEpisode":true,"autoPlayCountdownSeconds":10}"#; + let parsed: VideoSettings = serde_json::from_str(json).unwrap(); + assert!(parsed.auto_play_next_episode); + assert_eq!(parsed.auto_play_countdown_seconds, 10); + assert_eq!(parsed.auto_play_max_episodes, 0); } } diff --git a/src-tauri/src/storage/schema.rs b/src-tauri/src/storage/schema.rs index fa8dd59..89fa4bf 100644 --- a/src-tauri/src/storage/schema.rs +++ b/src-tauri/src/storage/schema.rs @@ -18,6 +18,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[ ("013_downloads_item_status_index", MIGRATION_013), ("014_series_audio_preferences", MIGRATION_014), ("015_device_id", MIGRATION_015), + ("016_autoplay_max_episodes", MIGRATION_016), ]; /// Initial schema migration @@ -656,3 +657,11 @@ CREATE TABLE IF NOT EXISTS app_settings ( -- Create index for efficient lookups (though key is already primary key) CREATE INDEX IF NOT EXISTS idx_app_settings_key ON app_settings(key); "#; + +/// Migration to add autoplay episode limit setting +/// - Adds autoplay_max_episodes column to user_player_settings +/// - 0 = unlimited (default), any positive value limits consecutive auto-plays +const MIGRATION_016: &str = r#" +ALTER TABLE user_player_settings +ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0; +"#; diff --git a/src/lib/api/autoplay.ts b/src/lib/api/autoplay.ts index e82b7b3..e1e9963 100644 --- a/src/lib/api/autoplay.ts +++ b/src/lib/api/autoplay.ts @@ -9,6 +9,7 @@ import { invoke } from "@tauri-apps/api/core"; export interface AutoplaySettings { enabled: boolean; countdownSeconds: number; + maxEpisodes: number; } export async function getAutoplaySettings(): Promise { diff --git a/src/lib/components/common/ScrollPicker.svelte b/src/lib/components/common/ScrollPicker.svelte new file mode 100644 index 0000000..68e4e40 --- /dev/null +++ b/src/lib/components/common/ScrollPicker.svelte @@ -0,0 +1,117 @@ + + +
+ +
+ + +
+
+ + +
+ + {#each Array(paddingCount) as _} +
+ {/each} + + + {#each items as item, i} + + {/each} + + + {#each Array(paddingCount) as _} +
+ {/each} +
+
+ + diff --git a/src/lib/components/library/GenericMediaListPage.svelte b/src/lib/components/library/GenericMediaListPage.svelte index 8aaae6d..c8a9eb7 100644 --- a/src/lib/components/library/GenericMediaListPage.svelte +++ b/src/lib/components/library/GenericMediaListPage.svelte @@ -45,6 +45,7 @@ let sortBy = $state(""); let sortOrder = $state<"Ascending" | "Descending">("Ascending"); let searchTimeout: ReturnType | null = null; + let initialLoadDone = false; $effect(() => { sortBy = config.defaultSort; @@ -57,6 +58,7 @@ onMount(async () => { await loadItems(); markLoaded(); + initialLoadDone = true; }); async function loadItems() { @@ -97,10 +99,12 @@ searchQuery = query; } - // Debounce search input (300ms delay) + // Debounce search input (300ms delay) - skip initial mount to avoid duplicate load $effect(() => { - if (searchTimeout) clearTimeout(searchTimeout); + const _query = searchQuery; // track for reactivity + if (!initialLoadDone) return; + if (searchTimeout) clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { debouncedSearchQuery = searchQuery; loadItems(); diff --git a/src/lib/components/player/AudioPlayer.svelte b/src/lib/components/player/AudioPlayer.svelte index 3d5839f..d435c90 100644 --- a/src/lib/components/player/AudioPlayer.svelte +++ b/src/lib/components/player/AudioPlayer.svelte @@ -202,7 +202,8 @@ aria-label="Sleep timer" > - + {#if $sleepTimerActive} diff --git a/src/lib/components/player/Controls.svelte b/src/lib/components/player/Controls.svelte index 70d3104..4b75b0c 100644 --- a/src/lib/components/player/Controls.svelte +++ b/src/lib/components/player/Controls.svelte @@ -1,5 +1,6 @@ -
+

Audio Settings

Configure playback and audio processing

@@ -359,6 +370,29 @@ 30s
+ + +
+
+

Episode Limit

+

+ Stop auto-playing after this many consecutive episodes +

+
+
+ {#each episodeLimitOptions as option} + + {/each} +
+
{/if}