improvements to the sleep timer
This commit is contained in:
parent
e8e37649fa
commit
c5be9eb18c
16
README.md
16
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-020 | Select subtitles for video content | High | Planned |
|
||||||
| UR-021 | Select audio track 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-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-024 | View recently added content on server | Medium | Done |
|
||||||
| UR-025 | Sync watch history and progress back to Jellyfin | High | 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-027 | Audio equalizer for sound customization | Low | Planned |
|
||||||
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
|
| 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 |
|
| 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-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done |
|
||||||
| DR-027 | "Recently Added" home section | UI | UR-024 | Done |
|
| DR-027 | "Recently Added" home section | UI | UR-024 | Done |
|
||||||
| DR-028 | Playback progress sync service (periodic reporting) | Player | UR-025 | 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-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-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 |
|
| 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-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-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-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-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) | Settings | UR-023, UR-026 | 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-020 | IR-016, IR-018 | DR-023 |
|
||||||
| UR-021 | IR-016, IR-019 | DR-024 |
|
| UR-021 | IR-016, IR-019 | DR-024 |
|
||||||
| UR-022 | IR-017 | DR-025 |
|
| 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-024 | IR-010 | DR-027 |
|
||||||
| UR-025 | IR-015 | DR-028 |
|
| 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-027 | IR-020 | DR-030 |
|
||||||
| UR-028 | - | DR-031 |
|
| UR-028 | - | DR-031 |
|
||||||
| UR-029 | - | DR-032 |
|
| UR-029 | - | DR-032 |
|
||||||
|
|||||||
@ -1231,11 +1231,24 @@ pub async fn player_get_audio_settings(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn player_set_video_settings(
|
pub async fn player_set_video_settings(
|
||||||
video_settings: State<'_, VideoSettingsWrapper>,
|
video_settings: State<'_, VideoSettingsWrapper>,
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
settings: VideoSettings,
|
settings: VideoSettings,
|
||||||
) -> Result<VideoSettings, String> {
|
) -> Result<VideoSettings, String> {
|
||||||
|
let validated = settings.with_countdown_clamped();
|
||||||
|
{
|
||||||
let mut current = video_settings.0.lock().map_err(|e| e.to_string())?;
|
let mut current = video_settings.0.lock().map_err(|e| e.to_string())?;
|
||||||
*current = settings.with_countdown_clamped();
|
*current = validated.clone();
|
||||||
Ok(current.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]
|
#[tauri::command]
|
||||||
@ -2333,16 +2346,18 @@ pub async fn player_set_autoplay_settings(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"INSERT INTO user_player_settings (user_id, autoplay_next_episode, autoplay_countdown_seconds, updated_at)
|
"INSERT INTO user_player_settings (user_id, autoplay_next_episode, autoplay_countdown_seconds, autoplay_max_episodes, updated_at)
|
||||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(user_id) DO UPDATE SET
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
autoplay_next_episode = excluded.autoplay_next_episode,
|
autoplay_next_episode = excluded.autoplay_next_episode,
|
||||||
autoplay_countdown_seconds = excluded.autoplay_countdown_seconds,
|
autoplay_countdown_seconds = excluded.autoplay_countdown_seconds,
|
||||||
|
autoplay_max_episodes = excluded.autoplay_max_episodes,
|
||||||
updated_at = CURRENT_TIMESTAMP",
|
updated_at = CURRENT_TIMESTAMP",
|
||||||
vec![
|
vec![
|
||||||
QueryParam::String(user_id),
|
QueryParam::String(user_id),
|
||||||
QueryParam::Int(if validated.enabled { 1 } else { 0 }),
|
QueryParam::Int(if validated.enabled { 1 } else { 0 }),
|
||||||
QueryParam::Int(validated.countdown_seconds as i32),
|
QueryParam::Int(validated.countdown_seconds as i32),
|
||||||
|
QueryParam::Int(validated.max_episodes as i32),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -5,8 +5,8 @@ use std::time::Duration;
|
|||||||
const APP_NAME: &str = "JellyTau";
|
const APP_NAME: &str = "JellyTau";
|
||||||
const APP_VERSION: &str = "0.1.0";
|
const APP_VERSION: &str = "0.1.0";
|
||||||
|
|
||||||
// Default timeout for requests (10 seconds)
|
// Default timeout for requests (30 seconds - large library queries can be slow)
|
||||||
const DEFAULT_TIMEOUT_MS: u64 = 10000;
|
const DEFAULT_TIMEOUT_MS: u64 = 30000;
|
||||||
|
|
||||||
// Retry configuration - matches TypeScript exactly
|
// Retry configuration - matches TypeScript exactly
|
||||||
const DEFAULT_MAX_RETRIES: u32 = 3;
|
const DEFAULT_MAX_RETRIES: u32 = 3;
|
||||||
|
|||||||
@ -28,6 +28,9 @@ pub struct AutoplaySettings {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Countdown duration in seconds before auto-playing next episode
|
/// Countdown duration in seconds before auto-playing next episode
|
||||||
pub countdown_seconds: u32,
|
pub countdown_seconds: u32,
|
||||||
|
/// Maximum number of episodes to auto-play consecutively (0 = unlimited)
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_episodes: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AutoplaySettings {
|
impl Default for AutoplaySettings {
|
||||||
@ -35,6 +38,7 @@ impl Default for AutoplaySettings {
|
|||||||
Self {
|
Self {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
countdown_seconds: 10,
|
countdown_seconds: 10,
|
||||||
|
max_episodes: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -56,6 +60,24 @@ mod tests {
|
|||||||
let settings = AutoplaySettings::default();
|
let settings = AutoplaySettings::default();
|
||||||
assert!(settings.enabled);
|
assert!(settings.enabled);
|
||||||
assert_eq!(settings.countdown_seconds, 10);
|
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]
|
#[test]
|
||||||
@ -63,6 +85,7 @@ mod tests {
|
|||||||
let settings = AutoplaySettings {
|
let settings = AutoplaySettings {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
countdown_seconds: 2, // Too short
|
countdown_seconds: 2, // Too short
|
||||||
|
max_episodes: 0,
|
||||||
}
|
}
|
||||||
.with_validated_countdown();
|
.with_validated_countdown();
|
||||||
assert_eq!(settings.countdown_seconds, 5); // Clamped to min
|
assert_eq!(settings.countdown_seconds, 5); // Clamped to min
|
||||||
@ -70,6 +93,7 @@ mod tests {
|
|||||||
let settings = AutoplaySettings {
|
let settings = AutoplaySettings {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
countdown_seconds: 60, // Too long
|
countdown_seconds: 60, // Too long
|
||||||
|
max_episodes: 0,
|
||||||
}
|
}
|
||||||
.with_validated_countdown();
|
.with_validated_countdown();
|
||||||
assert_eq!(settings.countdown_seconds, 30); // Clamped to max
|
assert_eq!(settings.countdown_seconds, 30); // Clamped to max
|
||||||
@ -77,6 +101,7 @@ mod tests {
|
|||||||
let settings = AutoplaySettings {
|
let settings = AutoplaySettings {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
countdown_seconds: 15, // Valid
|
countdown_seconds: 15, // Valid
|
||||||
|
max_episodes: 0,
|
||||||
}
|
}
|
||||||
.with_validated_countdown();
|
.with_validated_countdown();
|
||||||
assert_eq!(settings.countdown_seconds, 15); // Unchanged
|
assert_eq!(settings.countdown_seconds, 15); // Unchanged
|
||||||
|
|||||||
@ -44,7 +44,7 @@ pub use android::{
|
|||||||
set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
|
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::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
@ -86,6 +86,9 @@ pub struct PlayerController {
|
|||||||
|
|
||||||
// End reason tracking for autoplay decision making
|
// End reason tracking for autoplay decision making
|
||||||
end_reason: Arc<Mutex<Option<EndReason>>>,
|
end_reason: Arc<Mutex<Option<EndReason>>>,
|
||||||
|
|
||||||
|
// Auto-play episode counter (session-based, resets on manual play)
|
||||||
|
autoplay_episode_count: Arc<Mutex<u32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerController {
|
impl PlayerController {
|
||||||
@ -107,6 +110,7 @@ impl PlayerController {
|
|||||||
playback_reporter,
|
playback_reporter,
|
||||||
position_throttler,
|
position_throttler,
|
||||||
end_reason: Arc::new(Mutex::new(None)),
|
end_reason: Arc::new(Mutex::new(None)),
|
||||||
|
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start background timer thread for sleep timer countdown
|
// Start background timer thread for sleep timer countdown
|
||||||
@ -161,11 +165,38 @@ impl PlayerController {
|
|||||||
self.end_reason.lock().unwrap().take()
|
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)
|
/// Load and play a single item (also sets the queue to contain only this item)
|
||||||
pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
|
pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
|
||||||
debug!("[PlayerController] play_item: {}", item.title);
|
debug!("[PlayerController] play_item: {}", item.title);
|
||||||
|
|
||||||
|
// Reset autoplay counter on manual play
|
||||||
|
self.reset_autoplay_count();
|
||||||
|
|
||||||
// Update queue with this single item
|
// Update queue with this single item
|
||||||
{
|
{
|
||||||
let mut queue = self.queue.lock().unwrap();
|
let mut queue = self.queue.lock().unwrap();
|
||||||
@ -262,6 +293,9 @@ impl PlayerController {
|
|||||||
pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
|
pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
|
||||||
debug!("[PlayerController] play_queue: {} items, starting at index {}", items.len(), start_index);
|
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();
|
let mut queue = self.queue.lock().unwrap();
|
||||||
queue.set_queue(items, start_index);
|
queue.set_queue(items, start_index);
|
||||||
@ -374,6 +408,9 @@ impl PlayerController {
|
|||||||
/// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
|
/// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
|
||||||
/// from triggering when the current track's EndFile event fires
|
/// from triggering when the current track's EndFile event fires
|
||||||
pub fn next(&self) -> Result<(), PlayerError> {
|
pub fn next(&self) -> Result<(), PlayerError> {
|
||||||
|
// Reset autoplay counter on manual skip
|
||||||
|
self.reset_autoplay_count();
|
||||||
|
|
||||||
let next_item = {
|
let next_item = {
|
||||||
let mut queue = self.queue.lock().unwrap();
|
let mut queue = self.queue.lock().unwrap();
|
||||||
queue.next().cloned()
|
queue.next().cloned()
|
||||||
@ -394,6 +431,8 @@ impl PlayerController {
|
|||||||
/// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
|
/// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
|
||||||
/// from triggering when the current track's EndFile event fires
|
/// from triggering when the current track's EndFile event fires
|
||||||
pub fn previous(&self) -> Result<(), PlayerError> {
|
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
|
// If we're more than 3 seconds in, restart current track
|
||||||
{
|
{
|
||||||
let backend = self.backend.lock().unwrap();
|
let backend = self.backend.lock().unwrap();
|
||||||
@ -544,6 +583,7 @@ impl PlayerController {
|
|||||||
fn start_timer_thread(&self) {
|
fn start_timer_thread(&self) {
|
||||||
let sleep_timer = self.sleep_timer.clone();
|
let sleep_timer = self.sleep_timer.clone();
|
||||||
let event_emitter = self.event_emitter.clone();
|
let event_emitter = self.event_emitter.clone();
|
||||||
|
let backend = self.backend.clone();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
@ -553,6 +593,27 @@ impl PlayerController {
|
|||||||
if timer.is_active() {
|
if timer.is_active() {
|
||||||
timer.update_remaining_seconds();
|
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
|
// Emit update event
|
||||||
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
|
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
||||||
@ -673,6 +734,16 @@ impl PlayerController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match &timer_mode {
|
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 => {
|
SleepTimerMode::EndOfTrack => {
|
||||||
// Stop at end of track
|
// Stop at end of track
|
||||||
self.sleep_timer.lock().unwrap().cancel();
|
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 current.media_type == MediaType::Video && self.is_episode_item(¤t).await {
|
||||||
if let Some(next_ep) = self.fetch_next_episode_for_item(¤t).await? {
|
if let Some(next_ep) = self.fetch_next_episode_for_item(¤t).await? {
|
||||||
let settings = self.autoplay_settings.lock().unwrap().clone();
|
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 {
|
return Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||||
current_episode: next_ep.0, // Repository MediaItem
|
current_episode: next_ep.0, // Repository MediaItem
|
||||||
next_episode: next_ep.1,
|
next_episode: next_ep.1,
|
||||||
countdown_seconds: settings.countdown_seconds,
|
countdown_seconds: settings.countdown_seconds,
|
||||||
auto_advance: settings.enabled,
|
auto_advance: settings.enabled && !limit_reached,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// No next episode found
|
// No next episode found
|
||||||
|
|||||||
@ -469,8 +469,8 @@ impl MediaRepository for OnlineRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always request backdrop image fields
|
// Request image fields for list views (People only needed in get_item detail view)
|
||||||
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,People");
|
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags");
|
||||||
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
|
|
||||||
@ -500,7 +500,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_str = limit.unwrap_or(16);
|
let limit_str = limit.unwrap_or(16);
|
||||||
let endpoint = format!(
|
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
|
self.user_id, parent_id, limit_str
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -518,7 +518,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_str = limit.unwrap_or(16);
|
let limit_str = limit.unwrap_or(16);
|
||||||
let mut endpoint = format!(
|
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
|
self.user_id, limit_str
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -540,7 +540,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_str = limit.unwrap_or(16);
|
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 {
|
if let Some(sid) = series_id {
|
||||||
endpoint.push_str(&format!("&SeriesId={}", sid));
|
endpoint.push_str(&format!("&SeriesId={}", sid));
|
||||||
@ -562,7 +562,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
// Fetch more items to account for grouping reducing the count
|
// Fetch more items to account for grouping reducing the count
|
||||||
let fetch_limit = limit_val * 3;
|
let fetch_limit = limit_val * 3;
|
||||||
let endpoint = format!(
|
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
|
self.user_id, fetch_limit
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -661,7 +661,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_str = limit.unwrap_or(16);
|
let limit_str = limit.unwrap_or(16);
|
||||||
let endpoint = format!(
|
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
|
self.user_id, limit_str
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -721,8 +721,8 @@ impl MediaRepository for OnlineRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always request backdrop image fields
|
// Request image fields for list views (People only needed in get_item detail view)
|
||||||
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,People");
|
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags");
|
||||||
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
Ok(SearchResult {
|
Ok(SearchResult {
|
||||||
@ -1142,7 +1142,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
|
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
|
||||||
|
|
||||||
let mut endpoint = format!(
|
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
|
self.user_id, person_id, limit
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1176,7 +1176,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
|
|
||||||
// Try the /Similar endpoint which works for most items
|
// Try the /Similar endpoint which works for most items
|
||||||
let endpoint = format!(
|
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
|
item_id, self.user_id, limit_str
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -65,6 +65,9 @@ pub struct VideoSettings {
|
|||||||
pub auto_play_next_episode: bool,
|
pub auto_play_next_episode: bool,
|
||||||
/// Countdown duration in seconds before auto-play (5-30 seconds)
|
/// Countdown duration in seconds before auto-play (5-30 seconds)
|
||||||
pub auto_play_countdown_seconds: u32,
|
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 {
|
impl Default for VideoSettings {
|
||||||
@ -72,6 +75,7 @@ impl Default for VideoSettings {
|
|||||||
Self {
|
Self {
|
||||||
auto_play_next_episode: true,
|
auto_play_next_episode: true,
|
||||||
auto_play_countdown_seconds: 10,
|
auto_play_countdown_seconds: 10,
|
||||||
|
auto_play_max_episodes: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -146,6 +150,7 @@ mod tests {
|
|||||||
let settings = VideoSettings::default();
|
let settings = VideoSettings::default();
|
||||||
assert!(settings.auto_play_next_episode);
|
assert!(settings.auto_play_next_episode);
|
||||||
assert_eq!(settings.auto_play_countdown_seconds, 10);
|
assert_eq!(settings.auto_play_countdown_seconds, 10);
|
||||||
|
assert_eq!(settings.auto_play_max_episodes, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -170,14 +175,27 @@ mod tests {
|
|||||||
let settings = VideoSettings {
|
let settings = VideoSettings {
|
||||||
auto_play_next_episode: false,
|
auto_play_next_episode: false,
|
||||||
auto_play_countdown_seconds: 15,
|
auto_play_countdown_seconds: 15,
|
||||||
|
auto_play_max_episodes: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&settings).unwrap();
|
let json = serde_json::to_string(&settings).unwrap();
|
||||||
assert!(json.contains("\"autoPlayNextEpisode\":false"));
|
assert!(json.contains("\"autoPlayNextEpisode\":false"));
|
||||||
assert!(json.contains("\"autoPlayCountdownSeconds\":15"));
|
assert!(json.contains("\"autoPlayCountdownSeconds\":15"));
|
||||||
|
assert!(json.contains("\"autoPlayMaxEpisodes\":5"));
|
||||||
|
|
||||||
let parsed: VideoSettings = serde_json::from_str(&json).unwrap();
|
let parsed: VideoSettings = serde_json::from_str(&json).unwrap();
|
||||||
assert!(!parsed.auto_play_next_episode);
|
assert!(!parsed.auto_play_next_episode);
|
||||||
assert_eq!(parsed.auto_play_countdown_seconds, 15);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
|||||||
("013_downloads_item_status_index", MIGRATION_013),
|
("013_downloads_item_status_index", MIGRATION_013),
|
||||||
("014_series_audio_preferences", MIGRATION_014),
|
("014_series_audio_preferences", MIGRATION_014),
|
||||||
("015_device_id", MIGRATION_015),
|
("015_device_id", MIGRATION_015),
|
||||||
|
("016_autoplay_max_episodes", MIGRATION_016),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Initial schema migration
|
/// 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 for efficient lookups (though key is already primary key)
|
||||||
CREATE INDEX IF NOT EXISTS idx_app_settings_key ON app_settings(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;
|
||||||
|
"#;
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
export interface AutoplaySettings {
|
export interface AutoplaySettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
countdownSeconds: number;
|
countdownSeconds: number;
|
||||||
|
maxEpisodes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAutoplaySettings(): Promise<AutoplaySettings> {
|
export async function getAutoplaySettings(): Promise<AutoplaySettings> {
|
||||||
|
|||||||
117
src/lib/components/common/ScrollPicker.svelte
Normal file
117
src/lib/components/common/ScrollPicker.svelte
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface PickerItem {
|
||||||
|
value: number | string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: PickerItem[];
|
||||||
|
selectedValue?: number | string;
|
||||||
|
visibleCount?: number;
|
||||||
|
itemHeight?: number;
|
||||||
|
onSelect?: (value: number | string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
selectedValue = items[0]?.value,
|
||||||
|
visibleCount = 3,
|
||||||
|
itemHeight = 56,
|
||||||
|
onSelect,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let scrollContainer: HTMLDivElement | undefined = $state();
|
||||||
|
let selectedIndex = $state(0);
|
||||||
|
|
||||||
|
const paddingCount = $derived(Math.floor(visibleCount / 2));
|
||||||
|
const containerHeight = $derived(visibleCount * itemHeight);
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
const scrollTop = scrollContainer.scrollTop;
|
||||||
|
const newIndex = Math.round(scrollTop / itemHeight);
|
||||||
|
if (newIndex >= 0 && newIndex < items.length && newIndex !== selectedIndex) {
|
||||||
|
selectedIndex = newIndex;
|
||||||
|
onSelect?.(items[newIndex].value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToIndex(index: number) {
|
||||||
|
scrollContainer?.scrollTo({
|
||||||
|
top: index * itemHeight,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize scroll position
|
||||||
|
$effect(() => {
|
||||||
|
if (scrollContainer) {
|
||||||
|
const idx = Math.max(0, items.findIndex((i) => i.value === selectedValue));
|
||||||
|
scrollContainer.scrollTop = idx * itemHeight;
|
||||||
|
selectedIndex = idx;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden rounded-lg"
|
||||||
|
style="height: {containerHeight}px"
|
||||||
|
>
|
||||||
|
<!-- Highlight band for center item -->
|
||||||
|
<div
|
||||||
|
class="absolute left-0 right-0 pointer-events-none z-10 border-y border-[var(--color-jellyfin)]/40 bg-[var(--color-jellyfin)]/5 rounded"
|
||||||
|
style="top: {paddingCount * itemHeight}px; height: {itemHeight}px"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Fade gradients -->
|
||||||
|
<div class="absolute top-0 left-0 right-0 h-10 bg-gradient-to-b from-[var(--color-surface)] to-transparent z-20 pointer-events-none"></div>
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-[var(--color-surface)] to-transparent z-20 pointer-events-none"></div>
|
||||||
|
|
||||||
|
<!-- Scrollable container -->
|
||||||
|
<div
|
||||||
|
bind:this={scrollContainer}
|
||||||
|
onscroll={handleScroll}
|
||||||
|
class="h-full overflow-y-auto scroll-snap-y scrollbar-none"
|
||||||
|
style="-webkit-overflow-scrolling: touch"
|
||||||
|
>
|
||||||
|
<!-- Top padding -->
|
||||||
|
{#each Array(paddingCount) as _}
|
||||||
|
<div style="height: {itemHeight}px"></div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Items -->
|
||||||
|
{#each items as item, i}
|
||||||
|
<button
|
||||||
|
onclick={() => scrollToIndex(i)}
|
||||||
|
class="w-full snap-center flex items-center justify-center transition-all duration-150
|
||||||
|
{i === selectedIndex
|
||||||
|
? 'text-white text-2xl font-bold'
|
||||||
|
: 'text-gray-500 text-lg font-medium opacity-60'}"
|
||||||
|
style="height: {itemHeight}px"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Bottom padding -->
|
||||||
|
{#each Array(paddingCount) as _}
|
||||||
|
<div style="height: {itemHeight}px"></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrollbar-none::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.scrollbar-none {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.scroll-snap-y {
|
||||||
|
scroll-snap-type: y mandatory;
|
||||||
|
}
|
||||||
|
.snap-center {
|
||||||
|
scroll-snap-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -45,6 +45,7 @@
|
|||||||
let sortBy = $state<string>("");
|
let sortBy = $state<string>("");
|
||||||
let sortOrder = $state<"Ascending" | "Descending">("Ascending");
|
let sortOrder = $state<"Ascending" | "Descending">("Ascending");
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let initialLoadDone = false;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
sortBy = config.defaultSort;
|
sortBy = config.defaultSort;
|
||||||
@ -57,6 +58,7 @@
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadItems();
|
await loadItems();
|
||||||
markLoaded();
|
markLoaded();
|
||||||
|
initialLoadDone = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadItems() {
|
async function loadItems() {
|
||||||
@ -97,10 +99,12 @@
|
|||||||
searchQuery = query;
|
searchQuery = query;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounce search input (300ms delay)
|
// Debounce search input (300ms delay) - skip initial mount to avoid duplicate load
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (searchTimeout) clearTimeout(searchTimeout);
|
const _query = searchQuery; // track for reactivity
|
||||||
|
if (!initialLoadDone) return;
|
||||||
|
|
||||||
|
if (searchTimeout) clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
debouncedSearchQuery = searchQuery;
|
debouncedSearchQuery = searchQuery;
|
||||||
loadItems();
|
loadItems();
|
||||||
|
|||||||
@ -202,7 +202,8 @@
|
|||||||
aria-label="Sleep timer"
|
aria-label="Sleep timer"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
</svg>
|
</svg>
|
||||||
{#if $sleepTimerActive}
|
{#if $sleepTimerActive}
|
||||||
<span class="absolute top-1 right-1 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full"></span>
|
<span class="absolute top-1 right-1 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full"></span>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
|
import { sleepTimerActive } from "$lib/stores/sleepTimer";
|
||||||
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -92,8 +93,28 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Sleep Timer Indicator -->
|
<!-- Sleep Timer -->
|
||||||
|
{#if !$sleepTimerActive}
|
||||||
|
<button
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSleepTimerClick?.();
|
||||||
|
}}
|
||||||
|
ontouchstart={(e) => e.stopPropagation()}
|
||||||
|
ontouchmove={(e) => e.stopPropagation()}
|
||||||
|
ontouchend={(e) => e.stopPropagation()}
|
||||||
|
class="p-2 rounded-full text-gray-400 hover:text-white transition-colors"
|
||||||
|
title="Sleep timer"
|
||||||
|
aria-label="Sleep timer"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
<SleepTimerIndicator onClick={onSleepTimerClick} />
|
<SleepTimerIndicator onClick={onSleepTimerClick} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Previous -->
|
<!-- Previous -->
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -30,6 +30,7 @@
|
|||||||
import { formatTime, calculateProgress } from "$lib/utils/playbackUnits";
|
import { formatTime, calculateProgress } from "$lib/utils/playbackUnits";
|
||||||
import { haptics } from "$lib/utils/haptics";
|
import { haptics } from "$lib/utils/haptics";
|
||||||
import { toast } from "$lib/stores/toast";
|
import { toast } from "$lib/stores/toast";
|
||||||
|
import { sleepTimerActive } from "$lib/stores/sleepTimer";
|
||||||
import Controls from "./Controls.svelte";
|
import Controls from "./Controls.svelte";
|
||||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||||
import CastButton from "$lib/components/sessions/CastButton.svelte";
|
import CastButton from "$lib/components/sessions/CastButton.svelte";
|
||||||
@ -368,7 +369,27 @@
|
|||||||
<!-- Cast Button (visible on all screen sizes) -->
|
<!-- Cast Button (visible on all screen sizes) -->
|
||||||
<CastButton size="sm" />
|
<CastButton size="sm" />
|
||||||
|
|
||||||
<!-- Sleep Timer Indicator -->
|
<!-- Sleep Timer Button (always visible on larger screens) -->
|
||||||
|
{#if !$sleepTimerActive}
|
||||||
|
<button
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSleepTimerClick?.();
|
||||||
|
}}
|
||||||
|
ontouchstart={(e) => e.stopPropagation()}
|
||||||
|
ontouchmove={(e) => e.stopPropagation()}
|
||||||
|
ontouchend={(e) => e.stopPropagation()}
|
||||||
|
class="p-2 rounded-full hover:bg-white/10 transition-colors hidden sm:block"
|
||||||
|
title="Sleep timer"
|
||||||
|
aria-label="Sleep timer"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<!-- Sleep Timer Indicator (shows when active) -->
|
||||||
<SleepTimerIndicator onClick={onSleepTimerClick} />
|
<SleepTimerIndicator onClick={onSleepTimerClick} />
|
||||||
|
|
||||||
<!-- Volume Control (Linux only) -->
|
<!-- Volume Control (Linux only) -->
|
||||||
|
|||||||
@ -5,24 +5,32 @@
|
|||||||
sleepTimerActive,
|
sleepTimerActive,
|
||||||
} from "$lib/stores/sleepTimer";
|
} from "$lib/stores/sleepTimer";
|
||||||
import { currentQueueItem } from "$lib/stores/queue";
|
import { currentQueueItem } from "$lib/stores/queue";
|
||||||
|
import ScrollPicker from "$lib/components/common/ScrollPicker.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
mediaType?: string; // Override queue-based detection (e.g. for video player)
|
||||||
}
|
}
|
||||||
|
|
||||||
let { isOpen = false, onClose }: Props = $props();
|
let { isOpen = false, onClose, mediaType }: Props = $props();
|
||||||
|
|
||||||
|
const timePickerItems = [
|
||||||
|
{ value: 15, label: "15 min" },
|
||||||
|
{ value: 30, label: "30 min" },
|
||||||
|
{ value: 45, label: "45 min" },
|
||||||
|
{ value: 60, label: "60 min" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let selectedMinutes = $state(30);
|
||||||
|
|
||||||
const timePresets = [15, 30, 45, 60];
|
|
||||||
const episodePresets = [1, 2, 3];
|
const episodePresets = [1, 2, 3];
|
||||||
|
|
||||||
const isEpisode = $derived($currentQueueItem?.type === "Episode");
|
const effectiveType = $derived(mediaType ?? $currentQueueItem?.type);
|
||||||
const isVideo = $derived(
|
const isEpisode = $derived(effectiveType === "Episode");
|
||||||
$currentQueueItem?.type === "Episode" || $currentQueueItem?.type === "Movie"
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleTimePreset(minutes: number) {
|
function handleSetTimer() {
|
||||||
sleepTimer.setTimeTimer(minutes);
|
sleepTimer.setTimeTimer(selectedMinutes);
|
||||||
onClose?.();
|
onClose?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,7 +70,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getEndOfTrackLabel(): string {
|
function getEndOfTrackLabel(): string {
|
||||||
const type = $currentQueueItem?.type;
|
const type = effectiveType;
|
||||||
if (type === "Episode") return "End of current episode";
|
if (type === "Episode") return "End of current episode";
|
||||||
if (type === "Movie") return "End of current film";
|
if (type === "Movie") return "End of current film";
|
||||||
return "End of current track";
|
return "End of current track";
|
||||||
@ -73,7 +81,7 @@
|
|||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/60 z-[60] flex items-end sm:items-center justify-center p-0 sm:p-4"
|
class="fixed inset-0 bg-black/60 z-[60] flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
onkeydown={(e) => { if (e.key === 'Escape') handleBackdropClick(); }}
|
onkeydown={(e) => { if (e.key === 'Escape') onClose?.(); }}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="sleep-timer-title"
|
aria-labelledby="sleep-timer-title"
|
||||||
@ -144,19 +152,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Time presets -->
|
<!-- Time roller -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Stop after time</h3>
|
<h3 class="text-sm font-medium text-gray-400 mb-3">Stop after time</h3>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="flex flex-col items-center gap-3">
|
||||||
{#each timePresets as minutes}
|
<ScrollPicker
|
||||||
|
items={timePickerItems}
|
||||||
|
selectedValue={selectedMinutes}
|
||||||
|
visibleCount={3}
|
||||||
|
itemHeight={56}
|
||||||
|
onSelect={(val) => { selectedMinutes = val as number; }}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onclick={() => handleTimePreset(minutes)}
|
onclick={handleSetTimer}
|
||||||
class="p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-center"
|
class="w-full py-3 rounded-lg bg-[var(--color-jellyfin)] text-white font-semibold hover:opacity-90 transition-opacity"
|
||||||
>
|
>
|
||||||
<span class="text-lg font-medium text-white">{minutes}</span>
|
Set {selectedMinutes} min timer
|
||||||
<span class="text-sm text-gray-400 ml-1">min</span>
|
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,10 @@
|
|||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import VolumeControl from "./VolumeControl.svelte";
|
import VolumeControl from "./VolumeControl.svelte";
|
||||||
|
import SleepTimerModal from "./SleepTimerModal.svelte";
|
||||||
|
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||||
import CachedImage from "../common/CachedImage.svelte";
|
import CachedImage from "../common/CachedImage.svelte";
|
||||||
|
import { sleepTimerActive } from "$lib/stores/sleepTimer";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
media: MediaItem | null;
|
media: MediaItem | null;
|
||||||
@ -20,15 +23,18 @@
|
|||||||
onReportStart?: (positionSeconds: number) => void;
|
onReportStart?: (positionSeconds: number) => void;
|
||||||
onReportStop?: (positionSeconds: number) => void;
|
onReportStop?: (positionSeconds: number) => void;
|
||||||
onEnded?: () => void; // Called when video playback ends naturally
|
onEnded?: () => void; // Called when video playback ends naturally
|
||||||
|
onNext?: () => void; // Called when user clicks next episode button
|
||||||
|
hasNext?: boolean; // Whether there is a next episode available
|
||||||
}
|
}
|
||||||
|
|
||||||
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded }: Props = $props();
|
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false }: Props = $props();
|
||||||
|
|
||||||
let videoElement: HTMLVideoElement | null = $state(null);
|
let videoElement: HTMLVideoElement | null = $state(null);
|
||||||
let isPlaying = $state(false);
|
let isPlaying = $state(false);
|
||||||
let currentTime = $state(0);
|
let currentTime = $state(0);
|
||||||
let isFullscreen = $state(false);
|
let isFullscreen = $state(false);
|
||||||
let showControls = $state(true);
|
let showControls = $state(true);
|
||||||
|
let showSleepTimerModal = $state(false);
|
||||||
let isBuffering = $state(false);
|
let isBuffering = $state(false);
|
||||||
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let seekOffset = $state(0); // Track offset when seeking in transcoded streams
|
let seekOffset = $state(0); // Track offset when seeking in transcoded streams
|
||||||
@ -1408,6 +1414,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Next Episode -->
|
||||||
|
{#if hasNext}
|
||||||
|
<button onclick={onNext} class="text-white hover:text-gray-300" aria-label="Next episode">
|
||||||
|
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
@ -1520,6 +1535,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Sleep Timer -->
|
||||||
|
{#if $sleepTimerActive}
|
||||||
|
<SleepTimerIndicator onClick={() => { showSleepTimerModal = true; }} />
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => { showSleepTimerModal = true; }}
|
||||||
|
class="text-white hover:text-gray-300"
|
||||||
|
aria-label="Sleep timer"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Volume Control -->
|
<!-- Volume Control -->
|
||||||
<VolumeControl size="md" />
|
<VolumeControl size="md" />
|
||||||
|
|
||||||
@ -1545,6 +1575,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SleepTimerModal isOpen={showSleepTimerModal} onClose={() => { showSleepTimerModal = false; }} mediaType={media?.type} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@keyframes fade-out {
|
@keyframes fade-out {
|
||||||
0% {
|
0% {
|
||||||
|
|||||||
@ -158,7 +158,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User menu -->
|
<!-- User menu -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="ml-auto flex items-center gap-3">
|
||||||
<span class="text-sm text-gray-400 hidden md:inline">{$currentUser?.name}</span>
|
<span class="text-sm text-gray-400 hidden md:inline">{$currentUser?.name}</span>
|
||||||
|
|
||||||
<!-- Desktop: Downloads icon -->
|
<!-- Desktop: Downloads icon -->
|
||||||
|
|||||||
@ -57,6 +57,7 @@
|
|||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let showResumeDialog = $state(false);
|
let showResumeDialog = $state(false);
|
||||||
let savedProgress = $state<{ positionSeconds: number; progressPercent: number } | null>(null);
|
let savedProgress = $state<{ positionSeconds: number; progressPercent: number } | null>(null);
|
||||||
|
let nextEpisode = $state<MediaItem | null>(null); // Next episode for video skip button
|
||||||
|
|
||||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let loadedItemId: string | null = null;
|
let loadedItemId: string | null = null;
|
||||||
@ -408,6 +409,11 @@
|
|||||||
|
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
|
// Fetch next episode for video episodes (for skip button)
|
||||||
|
if (isVideo && currentMedia) {
|
||||||
|
fetchNextEpisode(currentMedia);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("loadAndPlay error:", e);
|
console.error("loadAndPlay error:", e);
|
||||||
// Show detailed error including the full error object
|
// Show detailed error including the full error object
|
||||||
@ -520,6 +526,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchNextEpisode(media: MediaItem) {
|
||||||
|
nextEpisode = null;
|
||||||
|
if (media.type !== "Episode" || !media.seriesId) return;
|
||||||
|
try {
|
||||||
|
const repo = auth.getRepository();
|
||||||
|
const episodes = await repo.getNextUpEpisodes(media.seriesId, 1);
|
||||||
|
if (episodes.length > 0 && episodes[0].id !== media.id) {
|
||||||
|
nextEpisode = episodes[0];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch next episode:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSkipToNextEpisode() {
|
||||||
|
if (nextEpisode) {
|
||||||
|
goto(`/player/${nextEpisode.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
const hours = Math.floor(seconds / 3600);
|
const hours = Math.floor(seconds / 3600);
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
@ -588,6 +614,8 @@
|
|||||||
onReportProgress={handleReportProgress}
|
onReportProgress={handleReportProgress}
|
||||||
onReportStop={handleReportStop}
|
onReportStop={handleReportStop}
|
||||||
onEnded={handleVideoEnded}
|
onEnded={handleVideoEnded}
|
||||||
|
hasNext={nextEpisode !== null}
|
||||||
|
onNext={handleSkipToNextEpisode}
|
||||||
/>
|
/>
|
||||||
<NextEpisodePopup />
|
<NextEpisodePopup />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@ -23,8 +23,18 @@
|
|||||||
interface VideoSettings {
|
interface VideoSettings {
|
||||||
autoPlayNextEpisode: boolean;
|
autoPlayNextEpisode: boolean;
|
||||||
autoPlayCountdownSeconds: number;
|
autoPlayCountdownSeconds: number;
|
||||||
|
autoPlayMaxEpisodes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const episodeLimitOptions = [
|
||||||
|
{ value: 0, label: "Unlimited" },
|
||||||
|
{ value: 1, label: "1" },
|
||||||
|
{ value: 2, label: "2" },
|
||||||
|
{ value: 3, label: "3" },
|
||||||
|
{ value: 5, label: "5" },
|
||||||
|
{ value: 10, label: "10" },
|
||||||
|
];
|
||||||
|
|
||||||
let settings = $state<AudioSettings>({
|
let settings = $state<AudioSettings>({
|
||||||
crossfadeDuration: 0,
|
crossfadeDuration: 0,
|
||||||
gaplessPlayback: true,
|
gaplessPlayback: true,
|
||||||
@ -35,6 +45,7 @@
|
|||||||
let videoSettings = $state<VideoSettings>({
|
let videoSettings = $state<VideoSettings>({
|
||||||
autoPlayNextEpisode: true,
|
autoPlayNextEpisode: true,
|
||||||
autoPlayCountdownSeconds: 10,
|
autoPlayCountdownSeconds: 10,
|
||||||
|
autoPlayMaxEpisodes: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@ -172,7 +183,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-2xl mx-auto space-y-8 p-6">
|
<div class="max-w-2xl mx-auto space-y-8 p-6 pb-24 h-full overflow-y-auto">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Audio Settings</h1>
|
<h1 class="text-3xl font-bold text-white mb-2">Audio Settings</h1>
|
||||||
<p class="text-gray-400">Configure playback and audio processing</p>
|
<p class="text-gray-400">Configure playback and audio processing</p>
|
||||||
@ -359,6 +370,29 @@
|
|||||||
<span>30s</span>
|
<span>30s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Episode Limit -->
|
||||||
|
<div class="pt-4 border-t border-gray-700">
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-sm font-medium text-gray-300">Episode Limit</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Stop auto-playing after this many consecutive episodes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 md:grid-cols-6 gap-2">
|
||||||
|
{#each episodeLimitOptions as option}
|
||||||
|
<button
|
||||||
|
onclick={() => { videoSettings.autoPlayMaxEpisodes = option.value; }}
|
||||||
|
class="py-3 px-3 rounded-lg transition-all text-sm
|
||||||
|
{videoSettings.autoPlayMaxEpisodes === option.value
|
||||||
|
? 'bg-[var(--color-jellyfin)] text-white'
|
||||||
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
||||||
|
>
|
||||||
|
<div class="font-semibold">{option.label}</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user