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}