Skip to main content

#
jellytau_lib/commands/player/
timers.rs

    1//! Sleep-timer and autoplay commands.
    2//!
    3//! TRACES: UR-026, UR-023 | DR-029, DR-047, DR-049
    4//!
    5//! Thin command adapters over `PlayerController`'s sleep-timer and autoplay
    6//! logic, plus persistence of autoplay settings to the database.
    7
    8use std::sync::Arc;
    9use tauri::State;
    10
    11use super::{
    12    create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStateWrapper,
    13    PlayerStatus,
    14};
    15use crate::player::{AutoplaySettings, SleepTimerMode, SleepTimerState};
    16use crate::storage::db_service::{DatabaseService, Query, QueryParam};
    17
    18// ===== Sleep Timer Commands =====
    19
    20/// Set sleep timer mode
    21#[tauri::command]
    22#[specta::specta]
    23pub async fn player_set_sleep_timer(
    24    player: State<'_, PlayerStateWrapper>,
    25    mode: SleepTimerMode,
    26) -> Result<SleepTimerState, String> {
    27    let controller = player.0.lock().await;
    28    controller.set_sleep_timer(mode);
    29    Ok(controller.sleep_timer_state())
    30}
    31
    32/// Cancel sleep timer
    33#[tauri::command]
    34#[specta::specta]
    35pub async fn player_cancel_sleep_timer(
    36    player: State<'_, PlayerStateWrapper>,
    37) -> Result<SleepTimerState, String> {
    38    let controller = player.0.lock().await;
    39    controller.cancel_sleep_timer();
    40    Ok(controller.sleep_timer_state())
    41}
    42
    43/// Get current sleep timer state
    44#[tauri::command]
    45#[specta::specta]
    46pub async fn player_get_sleep_timer(
    47    player: State<'_, PlayerStateWrapper>,
    48) -> Result<SleepTimerState, String> {
    49    let controller = player.0.lock().await;
    50    Ok(controller.sleep_timer_state())
    51}
    52
    53// ===== Autoplay Commands =====
    54
    55/// Get autoplay settings
    56#[tauri::command]
    57#[specta::specta]
    58pub async fn player_get_autoplay_settings(
    59    player: State<'_, PlayerStateWrapper>,
    60) -> Result<AutoplaySettings, String> {
    61    let controller = player.0.lock().await;
    62    Ok(controller.autoplay_settings())
    63}
    64
    65/// Set autoplay settings and persist to database
    66#[tauri::command]
    67#[specta::specta]
    68pub async fn player_set_autoplay_settings(
    69    player: State<'_, PlayerStateWrapper>,
    70    db: State<'_, DatabaseWrapper>,
    71    user_id: String,
    72    settings: AutoplaySettings,
    73) -> Result<AutoplaySettings, String> {
    74    let validated = settings.with_validated_countdown();
    75
    76    // Set in controller
    77    {
    78        let controller = player.0.lock().await;
    79        controller.set_autoplay_settings(validated.clone());
    80    }
    81
    82    // Persist to database
    83    let db_service = {
    84        let database = db.0.lock().map_err(|e| e.to_string())?;
    85        Arc::new(database.service())
    86    };
    87
    88    let query = Query::with_params(
    89        "INSERT INTO user_player_settings (user_id, autoplay_next_episode, autoplay_countdown_seconds, autoplay_max_episodes, updated_at)
    90         VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
    91         ON CONFLICT(user_id) DO UPDATE SET
    92            autoplay_next_episode = excluded.autoplay_next_episode,
    93            autoplay_countdown_seconds = excluded.autoplay_countdown_seconds,
    94            autoplay_max_episodes = excluded.autoplay_max_episodes,
    95            updated_at = CURRENT_TIMESTAMP",
    96        vec![
    97            QueryParam::String(user_id),
    98            QueryParam::Int(if validated.enabled { 1 } else { 0 }),
    99            QueryParam::Int(validated.countdown_seconds as i32),
    100            QueryParam::Int(validated.max_episodes as i32),
    101        ],
    102    );
    103
    104    db_service.execute(query).await.map_err(|e| e.to_string())?;
    105
    106    Ok(validated)
    107}
    108
    109/// Cancel active autoplay countdown
    110#[tauri::command]
    111#[specta::specta]
    112pub async fn player_cancel_autoplay_countdown(
    113    player: State<'_, PlayerStateWrapper>,
    114) -> Result<(), String> {
    115    let controller = player.0.lock().await;
    116    controller.cancel_autoplay_countdown();
    117    Ok(())
    118}
    119
    120/// Play next episode (user confirmed from popup)
    121#[tauri::command]
    122#[specta::specta]
    123pub async fn player_play_next_episode(
    124    player: State<'_, PlayerStateWrapper>,
    125    db: State<'_, DatabaseWrapper>,
    126    item: PlayItemRequest,
    127) -> Result<PlayerStatus, String> {
    128    // Convert request to MediaItem
    129    let media_item = create_media_item(item, Some(&db)).await?;
    130
    131    let controller = player.0.lock().await;
    132    controller
    133        .play_item(media_item)
    134        .map_err(|e| e.to_string())?;
    135
    136    Ok(get_player_status(&controller))
    137}
    138
    139/// Handle playback ended event - triggers autoplay decision logic
    140/// This is called from:
    141/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
    142/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
    143/// - Android JNI callback also triggers this logic directly
    144///
    145/// TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052, DR-129
    146#[tauri::command]
    147#[specta::specta]
    148pub async fn player_on_playback_ended(
    149    player: State<'_, PlayerStateWrapper>,
    150    repository_manager: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
    151    db: State<'_, DatabaseWrapper>,
    152    item_id: Option<String>,
    153    repository_handle: Option<String>,
    154) -> Result<(), String> {
    155    use crate::player::autoplay::AutoplayDecision;
    156    use crate::player::PlayerStatusEvent;
    157
    158    let controller_arc = player.0.clone();
    159
    160    // Run autoplay decision logic
    161    // If item_id is provided (HTML5 video case), use the video-specific path
    162    // that bypasses the backend queue and stale end_reason
    163    let decision = {
    164        let controller = controller_arc.lock().await;
    165        if let Some(ref id) = item_id {
    166            // Video path: need repository to look up episode info
    167            let repo = repository_handle
    168                .as_ref()
    169                .and_then(|handle| repository_manager.0.get(handle));
    170            if let Some(repo) = repo {
    171                controller.on_video_playback_ended(id, repo).await?
    172            } else {
    173                log::warn!(
    174                    "[Autoplay] No repository available for video autoplay (itemId: {})",
    175                    id
    176                );
    177                AutoplayDecision::Stop
    178            }
    179        } else {
    180            controller.on_playback_ended().await?
    181        }
    182    };
    183
    184    // Handle the decision
    185    match decision {
    186        AutoplayDecision::Stop => {
    187            log::info!("[Autoplay] Decision: Stop playback");
    188            let controller = controller_arc.lock().await;
    189            // Clear the queue so the frontend's currentQueueItem becomes null and
    190            // the mini player hides. Without this, the queue still holds the last
    191            // track and the bar would linger (the frontend keeps the bar visible
    192            // through transient idle blips as long as a queue item exists).
    193            controller.clear_queue();
    194            controller.emit_queue_changed();
    195            if let Some(emitter) = controller.event_emitter() {
    196                // Emit StateChanged to idle to clear the current media from mini player
    197                // Note: Do NOT emit PlaybackEnded here - it would cause an infinite loop
    198                // (frontend receives PlaybackEnded → calls player_on_playback_ended → Stop → PlaybackEnded → ...)
    199                emitter.emit(PlayerStatusEvent::StateChanged {
    200                    state: "idle".to_string(),
    201                    media_id: None,
    202                });
    203            }
    204        }
    205        AutoplayDecision::AdvanceToNext => {
    206            log::info!("[Autoplay] Decision: Advance to next track");
    207            // Advance to next track in queue
    208            let controller = controller_arc.lock().await;
    209            // Prefer downloads that completed since the queue was built (e.g.
    210            // preloaded upcoming tracks) over continuing to stream.
    211            if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await {
    212                log::warn!("[Autoplay] Failed to refresh local sources: {}", e);
    213            }
    214            if let Err(e) = controller.next() {
    215                log::error!("[Autoplay] Failed to advance to next track: {}", e);
    216                // Emit PlaybackEnded event on error
    217                if let Some(emitter) = controller.event_emitter() {
    218                    emitter.emit(PlayerStatusEvent::PlaybackEnded);
    219                }
    220            } else {
    221                // Emit queue changed event so frontend updates UI with new current track
    222                controller.emit_queue_changed();
    223            }
    224        }
    225        AutoplayDecision::ShowNextEpisodePopup {
    226            current_episode,
    227            next_episode,
    228            countdown_seconds,
    229            auto_advance,
    230        } => {
    231            log::info!(
    232                "[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})",
    233                countdown_seconds,
    234                auto_advance
    235            );
    236
    237            // Emit popup event to frontend
    238            if let Some(emitter) = controller_arc.lock().await.event_emitter() {
    239                emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup {
    240                    current_episode: current_episode.clone(),
    241                    next_episode: next_episode.clone(),
    242                    countdown_seconds,
    243                    auto_advance,
    244                });
    245            }
    246
    247            // Advance if auto_advance is enabled. This is the path that actually
    248            // runs on Android: the JNI callback's own decision is swallowed by the
    249            // NewTrackLoaded end reason set at load, so it returns Stop, emits
    250            // PlaybackEnded, and the frontend echoes it back into this command —
    251            // which is where the real decision lands.
    252            if auto_advance {
    253                controller_arc
    254                    .lock()
    255                    .await
    256                    .auto_advance_to_next_episode(next_episode, countdown_seconds)
    257                    .await;
    258            }
    259        }
    260        AutoplayDecision::ResumeStream { position } => {
    261            // The stream was cut short by the network, not by the media ending.
    262            // Re-open it where it died — no queue clearing, no PlaybackEnded, and
    263            // above all no leaving the player parked in ExoPlayer's STATE_ENDED,
    264            // where the next play intent restarts the item from 0:00.
    265            log::info!(
    266                "[Autoplay] Decision: Resume truncated stream at {:.1}s",
    267                position
    268            );
    269            let controller = controller_arc.lock().await;
    270            if let Err(e) = controller.resume_stream_at(position).await {
    271                log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
    272                if let Some(emitter) = controller.event_emitter() {
    273                    emitter.emit(PlayerStatusEvent::PlaybackEnded);
    274                }
    275            }
    276        }
    277    }
    278
    279    Ok(())
    280}
    281
    282/// Try to recover playback after a **recoverable** player error, reporting
    283/// whether it was handled.
    284///
    285/// The frontend's error handler stops the player, which is right for a real
    286/// failure and wrong for a network blip — it turned every hiccup into "playback
    287/// died". This is the echo path for backends that cannot decide in-process:
    288/// MpvBackend is constructed before `PlayerController` exists ([`lib.rs`]), so
    289/// its event thread has no controller to ask. It emits the error, the frontend
    290/// echoes it here, and the decision stays in Rust — the same shape as
    291/// `PlaybackEnded` → `player_on_playback_ended`.
    292///
    293/// Returns `true` when the stream was re-opened and the caller must NOT stop the
    294/// player; `false` when the error is real and should be surfaced as before.
    295/// Android decides inside its JNI callback and only emits errors it has already
    296/// declined to recover, so this reports `false` for those without a second
    297/// opinion — the shared attempt budget is spent by then either way.
    298///
    299/// TRACES: UR-004, UR-040 | DR-130 | UT-117
    300#[tauri::command]
    301#[specta::specta]
    302pub async fn player_recover_stream(player: State<'_, PlayerStateWrapper>) -> Result<bool, String> {
    303    let (position, delay_secs) = {
    304        let controller = player.0.lock().await;
    305        match controller.recoverable_error_resume() {
    306            Some(resume) => resume,
    307            None => return Ok(false),
    308        }
    309    };
    310
    311    log::warn!(
    312        "[Recovery] Stream failed — re-opening at {:.1}s in {}s",
    313        position,
    314        delay_secs
    315    );
    316    // Give a brief outage time to clear; retrying instantly just burns the budget.
    317    tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
    318
    319    let controller = player.0.lock().await;
    320    match controller.resume_stream_at(position).await {
    321        Ok(()) => Ok(true),
    322        Err(e) => {
    323            log::error!("[Recovery] Failed to re-open stream: {}", e);
    324            Ok(false)
    325        }
    326    }
    327}
    328
    329// ===== HTML5 video state-report commands =====
    330//
    331// On platforms where video renders in the webview (Linux WebKitGTK HTML5
    332// <video>), the real player lives outside the native backend, so the frontend
    333// HTML5 adapter reports DOM events back through these commands. The controller
    334// re-emits them through the same PlayerStatusEvent pipeline the native backends
    335// use, keeping the Rust controller the single source of truth and the frontend
    336// player store fed from one place (playerEvents.ts) in both modes.
    337
    338/// Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
    339#[tauri::command]
    340#[specta::specta]
    341pub async fn player_report_state(
    342    player: State<'_, PlayerStateWrapper>,
    343    state: String,
    344    media_id: Option<String>,
    345) -> Result<(), String> {
    346    let controller = player.0.lock().await;
    347    controller.report_html5_state(state, media_id);
    348    Ok(())
    349}
    350
    351/// Report an HTML5 <video> position tick (seconds). The adapter should throttle
    352/// these to roughly match the native backends' ~250ms cadence.
    353#[tauri::command]
    354#[specta::specta]
    355pub async fn player_report_position(
    356    player: State<'_, PlayerStateWrapper>,
    357    position: f64,
    358    duration: f64,
    359) -> Result<(), String> {
    360    let controller = player.0.lock().await;
    361    controller.report_html5_position(position, duration);
    362    Ok(())
    363}
    364
    365/// Report that the HTML5 <video> finished loading and knows its duration.
    366#[tauri::command]
    367#[specta::specta]
    368pub async fn player_report_media_loaded(
    369    player: State<'_, PlayerStateWrapper>,
    370    duration: f64,
    371) -> Result<(), String> {
    372    let controller = player.0.lock().await;
    373    controller.report_html5_media_loaded(duration);
    374    Ok(())
    375}