A recoverable player error meant "playback is over": the frontend's error handler stopped the player unconditionally, so a wifi blip killed the track. Android already decides in its JNI callback, but MpvBackend is constructed before PlayerController exists, so its event thread has no controller to ask. So MPV reports the failure and the frontend echoes it into the new player_recover_stream command — the same shape as PlaybackEnded -> player_on_playback_ended, keeping the decision in Rust. The command re-opens the stream where it stopped, with the existing attempt budget and backoff, and returns whether it handled it; only a false answer falls through to the old stop path. Android now reports the errors it has already declined as *unrecoverable*, so the echo never asks the same question twice. TRACES: UR-004, UR-040 | DR-130 | UT-117
376 lines
14 KiB
Rust
376 lines
14 KiB
Rust
//! Sleep-timer and autoplay commands.
|
|
//!
|
|
//! TRACES: UR-026, UR-023 | DR-029, DR-047, DR-049
|
|
//!
|
|
//! Thin command adapters over `PlayerController`'s sleep-timer and autoplay
|
|
//! logic, plus persistence of autoplay settings to the database.
|
|
|
|
use std::sync::Arc;
|
|
use tauri::State;
|
|
|
|
use super::{
|
|
create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStateWrapper,
|
|
PlayerStatus,
|
|
};
|
|
use crate::player::{AutoplaySettings, SleepTimerMode, SleepTimerState};
|
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
|
|
|
// ===== Sleep Timer Commands =====
|
|
|
|
/// Set sleep timer mode
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn player_set_sleep_timer(
|
|
player: State<'_, PlayerStateWrapper>,
|
|
mode: SleepTimerMode,
|
|
) -> Result<SleepTimerState, String> {
|
|
let controller = player.0.lock().await;
|
|
controller.set_sleep_timer(mode);
|
|
Ok(controller.sleep_timer_state())
|
|
}
|
|
|
|
/// Cancel sleep timer
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn player_cancel_sleep_timer(
|
|
player: State<'_, PlayerStateWrapper>,
|
|
) -> Result<SleepTimerState, String> {
|
|
let controller = player.0.lock().await;
|
|
controller.cancel_sleep_timer();
|
|
Ok(controller.sleep_timer_state())
|
|
}
|
|
|
|
/// Get current sleep timer state
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn player_get_sleep_timer(
|
|
player: State<'_, PlayerStateWrapper>,
|
|
) -> Result<SleepTimerState, String> {
|
|
let controller = player.0.lock().await;
|
|
Ok(controller.sleep_timer_state())
|
|
}
|
|
|
|
// ===== Autoplay Commands =====
|
|
|
|
/// Get autoplay settings
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn player_get_autoplay_settings(
|
|
player: State<'_, PlayerStateWrapper>,
|
|
) -> Result<AutoplaySettings, String> {
|
|
let controller = player.0.lock().await;
|
|
Ok(controller.autoplay_settings())
|
|
}
|
|
|
|
/// Set autoplay settings and persist to database
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn player_set_autoplay_settings(
|
|
player: State<'_, PlayerStateWrapper>,
|
|
db: State<'_, DatabaseWrapper>,
|
|
user_id: String,
|
|
settings: AutoplaySettings,
|
|
) -> Result<AutoplaySettings, String> {
|
|
let validated = settings.with_validated_countdown();
|
|
|
|
// Set in controller
|
|
{
|
|
let controller = player.0.lock().await;
|
|
controller.set_autoplay_settings(validated.clone());
|
|
}
|
|
|
|
// Persist to database
|
|
let db_service = {
|
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
|
Arc::new(database.service())
|
|
};
|
|
|
|
let query = Query::with_params(
|
|
"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),
|
|
],
|
|
);
|
|
|
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
|
|
|
Ok(validated)
|
|
}
|
|
|
|
/// Cancel active autoplay countdown
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn player_cancel_autoplay_countdown(
|
|
player: State<'_, PlayerStateWrapper>,
|
|
) -> Result<(), String> {
|
|
let controller = player.0.lock().await;
|
|
controller.cancel_autoplay_countdown();
|
|
Ok(())
|
|
}
|
|
|
|
/// Play next episode (user confirmed from popup)
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn player_play_next_episode(
|
|
player: State<'_, PlayerStateWrapper>,
|
|
db: State<'_, DatabaseWrapper>,
|
|
item: PlayItemRequest,
|
|
) -> Result<PlayerStatus, String> {
|
|
// Convert request to MediaItem
|
|
let media_item = create_media_item(item, Some(&db)).await?;
|
|
|
|
let controller = player.0.lock().await;
|
|
controller
|
|
.play_item(media_item)
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
Ok(get_player_status(&controller))
|
|
}
|
|
|
|
/// Handle playback ended event - triggers autoplay decision logic
|
|
/// This is called from:
|
|
/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
|
|
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
|
/// - Android JNI callback also triggers this logic directly
|
|
///
|
|
/// TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052, DR-129
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn player_on_playback_ended(
|
|
player: State<'_, PlayerStateWrapper>,
|
|
repository_manager: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
|
|
db: State<'_, DatabaseWrapper>,
|
|
item_id: Option<String>,
|
|
repository_handle: Option<String>,
|
|
) -> Result<(), String> {
|
|
use crate::player::autoplay::AutoplayDecision;
|
|
use crate::player::PlayerStatusEvent;
|
|
|
|
let controller_arc = player.0.clone();
|
|
|
|
// Run autoplay decision logic
|
|
// If item_id is provided (HTML5 video case), use the video-specific path
|
|
// that bypasses the backend queue and stale end_reason
|
|
let decision = {
|
|
let controller = controller_arc.lock().await;
|
|
if let Some(ref id) = item_id {
|
|
// Video path: need repository to look up episode info
|
|
let repo = repository_handle
|
|
.as_ref()
|
|
.and_then(|handle| repository_manager.0.get(handle));
|
|
if let Some(repo) = repo {
|
|
controller.on_video_playback_ended(id, repo).await?
|
|
} else {
|
|
log::warn!(
|
|
"[Autoplay] No repository available for video autoplay (itemId: {})",
|
|
id
|
|
);
|
|
AutoplayDecision::Stop
|
|
}
|
|
} else {
|
|
controller.on_playback_ended().await?
|
|
}
|
|
};
|
|
|
|
// Handle the decision
|
|
match decision {
|
|
AutoplayDecision::Stop => {
|
|
log::info!("[Autoplay] Decision: Stop playback");
|
|
let controller = controller_arc.lock().await;
|
|
// Clear the queue so the frontend's currentQueueItem becomes null and
|
|
// the mini player hides. Without this, the queue still holds the last
|
|
// track and the bar would linger (the frontend keeps the bar visible
|
|
// through transient idle blips as long as a queue item exists).
|
|
controller.clear_queue();
|
|
controller.emit_queue_changed();
|
|
if let Some(emitter) = controller.event_emitter() {
|
|
// Emit StateChanged to idle to clear the current media from mini player
|
|
// Note: Do NOT emit PlaybackEnded here - it would cause an infinite loop
|
|
// (frontend receives PlaybackEnded → calls player_on_playback_ended → Stop → PlaybackEnded → ...)
|
|
emitter.emit(PlayerStatusEvent::StateChanged {
|
|
state: "idle".to_string(),
|
|
media_id: None,
|
|
});
|
|
}
|
|
}
|
|
AutoplayDecision::AdvanceToNext => {
|
|
log::info!("[Autoplay] Decision: Advance to next track");
|
|
// Advance to next track in queue
|
|
let controller = controller_arc.lock().await;
|
|
// Prefer downloads that completed since the queue was built (e.g.
|
|
// preloaded upcoming tracks) over continuing to stream.
|
|
if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await {
|
|
log::warn!("[Autoplay] Failed to refresh local sources: {}", e);
|
|
}
|
|
if let Err(e) = controller.next() {
|
|
log::error!("[Autoplay] Failed to advance to next track: {}", e);
|
|
// Emit PlaybackEnded event on error
|
|
if let Some(emitter) = controller.event_emitter() {
|
|
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
|
}
|
|
} else {
|
|
// Emit queue changed event so frontend updates UI with new current track
|
|
controller.emit_queue_changed();
|
|
}
|
|
}
|
|
AutoplayDecision::ShowNextEpisodePopup {
|
|
current_episode,
|
|
next_episode,
|
|
countdown_seconds,
|
|
auto_advance,
|
|
} => {
|
|
log::info!(
|
|
"[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})",
|
|
countdown_seconds,
|
|
auto_advance
|
|
);
|
|
|
|
// Emit popup event to frontend
|
|
if let Some(emitter) = controller_arc.lock().await.event_emitter() {
|
|
emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup {
|
|
current_episode: current_episode.clone(),
|
|
next_episode: next_episode.clone(),
|
|
countdown_seconds,
|
|
auto_advance,
|
|
});
|
|
}
|
|
|
|
// Advance if auto_advance is enabled. This is the path that actually
|
|
// runs on Android: the JNI callback's own decision is swallowed by the
|
|
// NewTrackLoaded end reason set at load, so it returns Stop, emits
|
|
// PlaybackEnded, and the frontend echoes it back into this command —
|
|
// which is where the real decision lands.
|
|
if auto_advance {
|
|
controller_arc
|
|
.lock()
|
|
.await
|
|
.auto_advance_to_next_episode(next_episode, countdown_seconds)
|
|
.await;
|
|
}
|
|
}
|
|
AutoplayDecision::ResumeStream { position } => {
|
|
// The stream was cut short by the network, not by the media ending.
|
|
// Re-open it where it died — no queue clearing, no PlaybackEnded, and
|
|
// above all no leaving the player parked in ExoPlayer's STATE_ENDED,
|
|
// where the next play intent restarts the item from 0:00.
|
|
log::info!(
|
|
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
|
|
position
|
|
);
|
|
let controller = controller_arc.lock().await;
|
|
if let Err(e) = controller.resume_stream_at(position).await {
|
|
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
|
|
if let Some(emitter) = controller.event_emitter() {
|
|
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Try to recover playback after a **recoverable** player error, reporting
|
|
/// whether it was handled.
|
|
///
|
|
/// The frontend's error handler stops the player, which is right for a real
|
|
/// failure and wrong for a network blip — it turned every hiccup into "playback
|
|
/// died". This is the echo path for backends that cannot decide in-process:
|
|
/// MpvBackend is constructed before `PlayerController` exists ([`lib.rs`]), so
|
|
/// its event thread has no controller to ask. It emits the error, the frontend
|
|
/// echoes it here, and the decision stays in Rust — the same shape as
|
|
/// `PlaybackEnded` → `player_on_playback_ended`.
|
|
///
|
|
/// Returns `true` when the stream was re-opened and the caller must NOT stop the
|
|
/// player; `false` when the error is real and should be surfaced as before.
|
|
/// Android decides inside its JNI callback and only emits errors it has already
|
|
/// declined to recover, so this reports `false` for those without a second
|
|
/// opinion — the shared attempt budget is spent by then either way.
|
|
///
|
|
/// TRACES: UR-004, UR-040 | DR-130 | UT-117
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn player_recover_stream(player: State<'_, PlayerStateWrapper>) -> Result<bool, String> {
|
|
let (position, delay_secs) = {
|
|
let controller = player.0.lock().await;
|
|
match controller.recoverable_error_resume() {
|
|
Some(resume) => resume,
|
|
None => return Ok(false),
|
|
}
|
|
};
|
|
|
|
log::warn!(
|
|
"[Recovery] Stream failed — re-opening at {:.1}s in {}s",
|
|
position,
|
|
delay_secs
|
|
);
|
|
// Give a brief outage time to clear; retrying instantly just burns the budget.
|
|
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
|
|
|
|
let controller = player.0.lock().await;
|
|
match controller.resume_stream_at(position).await {
|
|
Ok(()) => Ok(true),
|
|
Err(e) => {
|
|
log::error!("[Recovery] Failed to re-open stream: {}", e);
|
|
Ok(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== HTML5 video state-report commands =====
|
|
//
|
|
// On platforms where video renders in the webview (Linux WebKitGTK HTML5
|
|
// <video>), the real player lives outside the native backend, so the frontend
|
|
// HTML5 adapter reports DOM events back through these commands. The controller
|
|
// re-emits them through the same PlayerStatusEvent pipeline the native backends
|
|
// use, keeping the Rust controller the single source of truth and the frontend
|
|
// player store fed from one place (playerEvents.ts) in both modes.
|
|
|
|
/// Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn player_report_state(
|
|
player: State<'_, PlayerStateWrapper>,
|
|
state: String,
|
|
media_id: Option<String>,
|
|
) -> Result<(), String> {
|
|
let controller = player.0.lock().await;
|
|
controller.report_html5_state(state, media_id);
|
|
Ok(())
|
|
}
|
|
|
|
/// Report an HTML5 <video> position tick (seconds). The adapter should throttle
|
|
/// these to roughly match the native backends' ~250ms cadence.
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn player_report_position(
|
|
player: State<'_, PlayerStateWrapper>,
|
|
position: f64,
|
|
duration: f64,
|
|
) -> Result<(), String> {
|
|
let controller = player.0.lock().await;
|
|
controller.report_html5_position(position, duration);
|
|
Ok(())
|
|
}
|
|
|
|
/// Report that the HTML5 <video> finished loading and knows its duration.
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn player_report_media_loaded(
|
|
player: State<'_, PlayerStateWrapper>,
|
|
duration: f64,
|
|
) -> Result<(), String> {
|
|
let controller = player.0.lock().await;
|
|
controller.report_html5_media_loaded(duration);
|
|
Ok(())
|
|
}
|