improvements to the sleep timer
This commit is contained in:
@@ -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<VideoSettings, String> {
|
||||
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),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Mutex<Option<EndReason>>>,
|
||||
|
||||
// Auto-play episode counter (session-based, resets on manual play)
|
||||
autoplay_episode_count: Arc<Mutex<u32>>,
|
||||
}
|
||||
|
||||
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<MediaItem>, 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
|
||||
|
||||
@@ -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<Vec<MediaItem>, 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<Vec<MediaItem>, 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<usize>,
|
||||
) -> Result<Vec<MediaItem>, 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<usize>) -> Result<Vec<MediaItem>, 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
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
"#;
|
||||
|
||||
Reference in New Issue
Block a user