refactor(player): delete the webview video path; mpv selects its own tracks

DR-235 phase 3. Every video renderer is native now: mpv on Linux and
Windows, ExoPlayer on Android, all drawing behind the transparent
webview. The HTML5 <video> path is gone, not bypassed:

- Frontend: hls.js, Html5PlayerAdapter and its compatibility shim, the
  createAdapter factory, streamTransport, hlsRecovery, timeTracking,
  videoFit, the <video>/<track> markup and every element handler in
  VideoPlayer (3277 -> 2144 lines), the experimentalNativeVideo store
  and its Settings toggle, webviewVideoFallback/supportsNativeVideo, and
  the setHtml5VideoState PiP bridge call. NativePlayerAdapter is the one
  video adapter; webview audio gets its own adapter kind.
- Rust: use_html5 dropped from player_seek_video,
  player_switch_audio_track and player_set_stream_quality with the
  Html5* strategies and ReloadStream responses; use_html5_element and
  VideoBackend dropped from PlayerStatus; player_play_item always loads
  the backend (set_current_item removed); Capabilities::webview removed;
  the WebKitGTK GStreamer/VAAPI setup (and its gst-inspect spawn) removed.
- Android: the HTML5 video state in PictureInPictureManager and
  ScreenWakeManager, and the bridge method feeding it.
- CSP: connect-src loses http:/https: and worker-src loses blob: -
  both existed for hls.js; with it gone they were only an exfiltration
  channel and a blob worker for injected script. A test now keeps them
  out.

mpv takes over what the <video> element did (mpv_tracks, UT-275):
subtitles are the WebVTT list the play request carries, queued on
sub-files and selected by position in that list, starting off; audio
tracks are selected by position in the file; sid/aid are reset before
each load. Without this, Linux video had no subtitle selection and a
direct-play audio switch failed since mpv became its renderer.

Verified: Rust 948 passing, and the same 948 cross-compiled for Windows
under wine against the shipped DLL (track tests included); frontend
1111 passing; aarch64 debug APK builds. Lint warnings 158 -> 146, CI
ratchet tightened to match. Not yet seen on Windows hardware.
This commit is contained in:
2026-09-24 23:11:17 -04:00
parent bb3ab1edd7
commit 1677f5f299
69 changed files with 4014 additions and 7330 deletions
+75 -256
View File
@@ -69,10 +69,6 @@ pub struct PlayerStatus {
pub muted: bool,
pub shuffle: bool,
pub repeat: RepeatMode,
/// Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
pub backend: VideoBackend,
/// Whether frontend should render HTML5 video element
pub use_html5_element: bool,
// Merged fields (prefer remote session when available)
/// Media item from either local queue or remote session
@@ -156,16 +152,6 @@ pub struct QueueStatus {
pub has_previous: bool,
}
/// Backend type for video playback
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum VideoBackend {
/// Native backend (ExoPlayer on Android, libmpv on Linux)
Native,
/// HTML5 video element fallback
Html5,
}
/// Request to play a single video item
///
/// Simplified to video playback only. Audio playback uses player_play_tracks
@@ -217,9 +203,8 @@ pub struct PlayItemRequest {
pub series_id: Option<String>,
/// Subtitle tracks to sideload, with URLs the frontend has already resolved.
///
/// Only the native backends use these: on Android they become the
/// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
/// builds its own `<track>` children instead and ignores this list.
/// On Android they become the `MediaItem.SubtitleConfiguration`s ExoPlayer
/// renders; mpv loads them as external subtitle files (`mpv_tracks`).
///
/// **Order is the contract.** `player_set_subtitle_track(n)` reaches
/// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
@@ -328,19 +313,6 @@ pub enum VideoSeekResponse {
/// Confirmed position after seek
position: f64,
},
/// Reload stream from new position (transcoded non-HLS)
ReloadStream {
/// What to open, and how — transport included, so the frontend picks
/// its loader from a tagged enum rather than by searching the URL for
/// `.m3u8`. TRACES: UR-079 | DR-225
selection: StreamSelection,
/// `seek_offset` carries the position to RESUME AT, not a base to add to
/// the element's clock. The reloaded stream starts at the item's zero —
/// a position on an HLS playlist makes the server 400 every segment
/// behind it (DR-181) — so the adapter reaches the position by seeking
/// the element and leaves the transcode offset at zero.
seek_offset: f64,
},
}
/// Response for audio track switching operations
@@ -352,13 +324,6 @@ pub enum AudioTrackSwitchResponse {
/// Confirmation message
success: bool,
},
/// HTML5 needs to reload stream with new audio track
ReloadStream {
/// What to open, and how. TRACES: UR-079 | DR-225
selection: StreamSelection,
/// Current position to resume from
position: f64,
},
}
/// Response for a mid-playback streaming-quality change.
@@ -388,16 +353,6 @@ pub enum StreamQualityResponse {
/// Position playback resumed at.
position: f64,
},
/// HTML5 must reload its element with this selection.
ReloadStream {
/// What to open, and how — already negotiated against the requested
/// ceiling. Carries `available` too, so a picker opened after a quality
/// change still describes the source correctly.
/// TRACES: UR-070, UR-079 | DR-225, DR-227
selection: StreamSelection,
/// Position to resume from.
position: f64,
},
}
/// Helper function to create MediaItem from video request
@@ -726,36 +681,18 @@ pub async fn player_play_item(
}
let controller = player.0.lock().await;
// Who gets the stream depends on who is going to *render* it, which is a
// runtime question, not a platform constant.
//
// Historically Linux video was always the webview's (`use_html5_element`),
// so handing the file to MPV as well would only have started a redundant
// decode with no window to show it in — hence a `#[cfg(not(linux))]` guard
// and a queue-only path here. With mpv drawing the picture that inverts:
// the webview is no longer loading anything, so if this does not load the
// file, *nothing does*. The symptom is total silence — no picture and no
// audio — which reads like a broken stream rather than a stream nobody was
// given.
//
// This is the fifth place in this cycle where a renderer's capability was
// written as a compile-time platform fact. Same fix as the others: ask.
// (It said `cfg!(not(linux))`, which also loaded Windows video into the
// backend while the status sent it to the `<video>` element.)
// The backend always gets the stream: every video renderer is native (mpv,
// ExoPlayer) since the webview path was deleted (DR-235). This used to ask
// who would render — the webview's `<video>` played it itself, so the
// backend was only told about it (`set_current_item`) — and got the answer
// wrong twice: the Linux guard silenced mpv video entirely once mpv drew the
// picture, and on Windows it loaded video into the backend while the status
// sent it to the element too.
//
// TRACES: UR-080 | DR-231, DR-235, DR-237
let renders_natively = video_renders_natively();
if renders_natively {
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
} else {
// The webview will play it; keep the queue in sync for the UI and for a
// remote transfer without starting a second decode.
controller
.set_current_item(media_item)
.map_err(|e| e.to_string())?;
}
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
// Emit queue changed event
controller.emit_queue_changed();
@@ -778,8 +715,8 @@ pub async fn player_play_item(
/// `stream_url` MUST be an audio-only URL (see
/// `get_audio_only_stream_url_for_video`). The item is created as
/// `MediaType::Audio` so it starts an audio session and loads into the native
/// backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
/// frontend side, so exactly one audio source is ever active.
/// backend with `mediaType="audio"`, replacing the video, so exactly one audio
/// source is ever active.
///
/// This deliberately goes through the queue-based `play_item` path (NOT a
/// side-channel) so end-of-track lands in `on_playback_ended`, which already
@@ -895,7 +832,7 @@ pub async fn player_enter_background_audio(
}
/// Exit background-audio mode: stop the native audio player and return its final
/// position so the frontend can reload the WebView `<video>` there (UR-040).
/// position so the frontend can reload the video there (UR-040).
///
/// Returns the position in seconds. The sleep timer is intentionally left
/// untouched — if it fired while backgrounded, playback is already stopped and
@@ -1190,8 +1127,9 @@ pub async fn player_stop(
let mode = playback_mode.0.get_mode();
// Stopping is a state transition worth seeing in a log. Native video is
// what made its absence matter: the webview <video> stopped implicitly when
// the component unmounted, so nothing ever had to call this — and "never
// what made its absence matter: the (since deleted) webview <video> stopped
// implicitly when the component unmounted, so nothing ever had to call
// this — and "never
// called" and "called but the backend kept playing" look identical from
// outside without it.
info!("[player_stop] called (mode: {:?})", mode);
@@ -1432,8 +1370,8 @@ pub async fn player_seek(
/// - Direct play streams: Use native seeking
/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
///
/// For native (non-HTML5) backends, this command handles the entire stream reload
/// internally. For HTML5 backends, it returns the new URL for the frontend to handle.
/// The backend always handles the seek itself, including re-opening a stream,
/// since every video renderer is native (DR-235).
#[tauri::command]
#[specta::specta]
pub async fn player_seek_video(
@@ -1443,12 +1381,8 @@ pub async fn player_seek_video(
position: f64,
media_source_id: Option<String>,
audio_stream_index: Option<i32>,
use_html5: bool,
) -> Result<VideoSeekResponse, String> {
info!(
"[player_seek_video] Seeking to {} seconds (use_html5: {})",
position, use_html5
);
info!("[player_seek_video] Seeking to {} seconds", position);
// Get repository
let repository = repository_manager
@@ -1490,17 +1424,13 @@ pub async fn player_seek_video(
let controller = player.0.lock().await;
controller.capabilities().seeks_transcoded_in_place
};
let strategy = determine_video_seek_strategy(
is_local,
seeks_transcoded_in_place,
needs_transcoding,
use_html5,
);
let strategy =
determine_video_seek_strategy(is_local, seeks_transcoded_in_place, needs_transcoding);
info!(
"[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
needs_transcoding={}, use_html5={}, strategy={:?}",
is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy
needs_transcoding={}, strategy={:?}",
is_local, seeks_transcoded_in_place, needs_transcoding, strategy
);
match strategy {
@@ -1511,35 +1441,6 @@ pub async fn player_seek_video(
controller.seek(position).map_err(|e| e.to_string())?;
Ok(VideoSeekResponse::Native { position })
}
VideoSeekStrategy::Html5NativeSeek => {
// HTML5 backend with HLS or direct play - frontend handles seeking
// We don't call backend.seek() because video is in HTML5 element, not in MPV
info!("[player_seek_video] HTML5 native seek - returning position for frontend");
Ok(VideoSeekResponse::Native { position })
}
VideoSeekStrategy::Html5ReloadStream => {
// Transcoded non-HLS with HTML5 - frontend handles stream reload
info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
audio_stream_index,
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
info!(
"[player_seek_video] Selected {:?} over {:?} for position {}",
selection.playback_kind, selection.transport, position
);
Ok(VideoSeekResponse::ReloadStream {
selection,
seek_offset: position,
})
}
VideoSeekStrategy::BackendReloadStream => {
// Transcoded non-HLS with native backend - backend handles stream reload
info!("[player_seek_video] Backend reload stream - requesting new stream URL");
@@ -1610,9 +1511,6 @@ pub async fn player_seek_video(
/// carries the requested track at all** — see
/// [`determine_audio_track_switch_strategy`]:
///
/// - An HTML5 `<video>` element has no track-selection API, so the stream is
/// always re-opened at the chosen `AudioStreamIndex` and the frontend seeks
/// the reloaded element back to `position`.
/// - A native backend playing a **direct play** holds the source file with
/// every track in it, so ExoPlayer selects in place by track-group index.
/// - A native backend playing a **transcode** does not. Jellyfin builds a
@@ -1628,10 +1526,8 @@ pub async fn player_seek_video(
/// audio track index` and dropped the request — the default track just kept
/// playing, with nothing in the UI saying so.
///
/// libmpv implements neither selection nor reload here — it is the audio-only
/// backend and leaves `PlayerBackend::set_audio_track` at its
/// `not_implemented()` default, which is why IR-019 is met by these paths
/// rather than by MPV.
/// mpv selects in place the same way (`mpv_tracks::select_audio`, by position in
/// the file's audio tracks), and re-opens a transcode through the same path.
///
/// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
#[tauri::command]
@@ -1646,12 +1542,13 @@ pub async fn player_switch_audio_track(
repository_handle: String,
stream_index: i32,
array_index: i32,
use_html5: bool,
current_position: Option<f64>,
media_source_id: Option<String>,
) -> Result<AudioTrackSwitchResponse, String> {
info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
stream_index, array_index, use_html5);
info!(
"[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}",
stream_index, array_index
);
// Read what the engine is playing before deciding anything — including
// where it is, which has to be captured before the stop below wipes it.
@@ -1675,11 +1572,11 @@ pub async fn player_switch_audio_track(
)
};
let strategy = determine_audio_track_switch_strategy(needs_transcoding, use_html5);
let strategy = determine_audio_track_switch_strategy(needs_transcoding);
info!(
"[player_switch_audio_track] needs_transcoding={}, use_html5={}, strategy={:?}",
needs_transcoding, use_html5, strategy
"[player_switch_audio_track] needs_transcoding={}, strategy={:?}",
needs_transcoding, strategy
);
if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace {
@@ -1700,7 +1597,7 @@ pub async fn player_switch_audio_track(
// Select a stream carrying the chosen audio track. It starts at zero —
// an HLS playlist cannot carry a position (DR-181) — so the position is
// restored by seeking afterwards, here or in the frontend.
// restored by seeking afterwards.
//
// Pinning a track is itself a reason the source cannot be direct-played:
// the file has one default track and the viewer asked for another, so
@@ -1721,10 +1618,6 @@ pub async fn player_switch_audio_track(
let position = crate::player::track_switch::resume_position(current_position, engine_position);
match strategy {
AudioTrackSwitchStrategy::Html5ReloadStream => Ok(AudioTrackSwitchResponse::ReloadStream {
selection,
position,
}),
AudioTrackSwitchStrategy::BackendReloadStream => {
// The native backend re-opens its own stream, the same sequence the
// transcoded seek and quality change use: stop, repoint the queue
@@ -1783,9 +1676,7 @@ pub async fn player_switch_audio_track(
/// A cap is a property of the stream the server is producing, so unlike a volume
/// change it cannot be applied to a stream already in flight — the stream has to
/// be re-opened at the new quality and resumed at the current position. That is
/// the same reload the transcoded-seek and audio-track paths use, and the same
/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
/// native backend is reloaded here.
/// the same reload the transcoded-seek and audio-track paths use, done here.
///
/// The change applies to **this playback only**. The in-player picker is a
/// "this film, this connection" control and its doc has always said so, but it
@@ -1809,15 +1700,13 @@ pub async fn player_set_stream_quality(
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
repository_handle: String,
quality: crate::settings::StreamingQuality,
use_html5: bool,
current_position: Option<f64>,
media_source_id: Option<String>,
audio_stream_index: Option<i32>,
) -> Result<StreamQualityResponse, String> {
info!(
"[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
"[player_set_stream_quality] Switching to {} (position: {:?})",
quality.label(),
use_html5,
current_position
);
@@ -1882,14 +1771,7 @@ pub async fn player_set_stream_quality(
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
let new_url = selection.url.clone();
if use_html5 {
return Ok(StreamQualityResponse::ReloadStream {
selection,
position,
});
}
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
// The native backend (mpv, ExoPlayer): stop, repoint the queue entry at the
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
// The re-opened stream begins at zero (an HLS playlist cannot carry a start
// position without 400ing every segment — DR-181), so it is seeked back to
@@ -1942,8 +1824,8 @@ pub async fn player_set_audio_track(
///
/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
/// index. The HTML5 path never reaches here; it toggles its own `<track>`
/// children. libmpv implements neither, leaving the trait default in place.
/// index. mpv gives it the same meaning: the position in the sideloaded WebVTT
/// list, loaded as external subtitle files (`mpv_tracks`).
///
/// TRACES: UR-020 | IR-018, DR-023
#[tauri::command]
@@ -2186,17 +2068,13 @@ pub async fn player_get_queue(
#[serde(rename_all = "camelCase")]
pub struct PlaybackCapabilities {
/// True when audio is rendered by a webview `<audio>` element rather than a
/// native backend. Native audio exists on Linux (mpv) and Android
/// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
/// native backend. Native audio exists on Linux and Windows (mpv) and
/// Android (ExoPlayer); only an unported desktop uses the webview.
///
/// Video has no counterpart: it is always drawn by the native backend, behind
/// the transparent webview (DR-235) — there is no webview video renderer
/// left to report.
pub uses_webview_audio: bool,
/// True when video is rendered by a native surface composited *behind* a
/// transparent webview: ExoPlayer's SurfaceView on Android, mpv's GL area on
/// Linux.
pub supports_native_video: bool,
/// True when the user may send video to the webview element instead of the
/// native renderer — the frontend offers the switch only then, and honours
/// the stored preference only then. False on every platform since DR-235.
pub webview_video_fallback: bool,
}
/// Report this platform's playback capabilities to the frontend.
@@ -2215,51 +2093,14 @@ pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
Ok(PlaybackCapabilities {
uses_webview_audio: !native_audio,
// TRACES: UR-080 | DR-235
supports_native_video: video_renders_natively(),
// No platform offers one: Android since DR-293, Linux since DR-235,
// and on Windows the webview is the only video renderer, so there is
// nothing to fall back *from*. Kept on the wire until phase 3 deletes
// the frontend switch with the rest of the webview video path.
// TRACES: UR-080, UR-003 | DR-235, DR-293
webview_video_fallback: false,
})
}
/// Whether a native renderer draws video on this platform, so the backend
/// must be handed the stream and the webview must not load it.
///
/// ExoPlayer on Android, mpv on Linux; the webview `<video>` element on
/// Windows until DR-237 gives it mpv video. Asked by `player_play_item`,
/// `get_player_status` and `player_get_capabilities` — the answer drifted when
/// each spelled it out for itself.
///
/// TRACES: UR-003, UR-080 | DR-235, DR-237
pub(crate) fn video_renders_natively() -> bool {
cfg!(target_os = "android") || crate::player::native_video::enabled()
}
pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
// Determine backend at compile time based on platform
let (backend, use_html5_element) = if cfg!(target_os = "android") {
// Android uses ExoPlayer native backend
(VideoBackend::Native, false)
} else if video_renders_natively() {
// mpv draws the picture on this desktop; the frontend must not also
// load it into a <video> element or the stream decodes twice and the
// two fight over the audio. TRACES: UR-080 | DR-235
(VideoBackend::Native, false)
} else {
// Windows: the webview <video> element is its only video renderer
// until mpv reaches it (DR-237).
(VideoBackend::Html5, true)
};
PlayerStatus {
state: controller.state(),
// The position on the item's timeline, whichever of the three paths is
// rendering it — the native backend answers for only one of them, and
// reads 0 for webview video and for a handoff that has not ticked yet.
// The position on the item's timeline, whichever path is rendering it —
// the native backend reads 0 for a handoff that has not ticked yet.
// TRACES: UR-005 | DR-178
position: controller.absolute_position(),
duration: controller.duration(),
@@ -2267,8 +2108,6 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
muted: controller.muted(),
shuffle: controller.is_shuffle(),
repeat: controller.repeat_mode(),
backend,
use_html5_element,
// Merged fields initialized to defaults (will be set by player_get_status)
merged_media: None,
@@ -3092,61 +2931,41 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
mod tests {
use crate::utils::lock::MutexSafe;
/// The webview is not a video renderer anywhere the app ships a native one:
/// Android since DR-293, Linux since DR-235 made mpv its only video path.
/// So the frontend is never offered the switch, and a stored "native video
/// off" from before cannot send Linux video back to the `<video>` element.
///
/// TRACES: UR-080, UR-003 | DR-235, DR-293 | UT-272
#[tokio::test]
async fn test_no_platform_offers_a_webview_video_fallback() {
let caps = super::player_get_capabilities().await.unwrap();
assert!(!caps.webview_video_fallback);
if cfg!(target_os = "linux") {
assert!(caps.supports_native_video, "mpv draws video on Linux");
assert!(!caps.uses_webview_audio);
}
}
/// The three places that answer "who draws video here" give one answer:
/// `play_item` loads the backend exactly where the status tells the
/// frontend *not* to use a `<video>` element. They disagreed on Windows —
/// `play_item` loaded video into the backend while the status sent it to
/// the element — which was invisible while that backend was the webview's
/// own `<audio>`, and would play every film's soundtrack twice once mpv
/// plays Windows audio.
/// Video always goes to the backend. `player_play_item` once decided per
/// platform whether the backend or the webview's `<video>` would render,
/// and each wrong answer was silence (Linux, once mpv drew the picture) or a
/// soundtrack decoded twice (Windows). With the webview video path deleted
/// there is no second renderer to route to, and the queue-only branch is
/// gone with it.
///
/// TRACES: UR-003, UR-080 | DR-235, DR-237 | UT-273
#[tokio::test]
async fn test_video_routing_has_one_answer() {
#[test]
fn test_video_always_goes_to_the_backend() {
let src = include_str!("mod.rs");
let routing = src
.split("let renders_natively =")
let play_item = src
.split("pub async fn player_play_item(")
.nth(1)
.and_then(|rest| rest.split(';').next())
.expect("player_play_item decides renders_natively");
assert_eq!(
routing.trim(),
"video_renders_natively()",
"player_play_item must ask the same question as get_player_status"
.and_then(|rest| rest.split("\n}\n").next())
.expect("player_play_item exists");
assert!(play_item.contains(".play_item(media_item)"));
assert!(
!play_item.contains(".set_current_item("),
"player_play_item must not keep video from the backend"
);
let status = super::get_player_status(&crate::player::PlayerController::default());
assert_eq!(status.use_html5_element, !super::video_renders_natively());
let caps = super::player_get_capabilities().await.unwrap();
assert_eq!(caps.supports_native_video, super::video_renders_natively());
}
/// And the status the video page reads agrees: on Linux the frontend is told
/// the native backend renders, never to load a `<video>` element.
/// Only audio can still be the webview's, and only on a desktop with no mpv.
///
/// TRACES: UR-080 | DR-235 | UT-272
#[test]
fn test_linux_video_is_not_sent_to_the_webview() {
let controller = crate::player::PlayerController::default();
let status = super::get_player_status(&controller);
if cfg!(target_os = "linux") {
assert!(!status.use_html5_element);
/// TRACES: UR-003, UR-080 | DR-235, DR-237 | UT-272
#[tokio::test]
async fn test_every_shipped_platform_plays_audio_natively() {
let caps = super::player_get_capabilities().await.unwrap();
if cfg!(any(
target_os = "linux",
target_os = "windows",
target_os = "android"
)) {
assert!(!caps.uses_webview_audio);
}
}
+10 -9
View File
@@ -138,7 +138,7 @@ pub async fn player_play_next_episode(
/// 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 a video ends - 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
///
@@ -158,7 +158,7 @@ pub async fn player_on_playback_ended(
let controller_arc = player.0.clone();
// Run autoplay decision logic
// If item_id is provided (HTML5 video case), use the video-specific path
// If item_id is provided (a video), use the video-specific path
// that bypasses the backend queue and stale end_reason
let decision = {
let controller = controller_arc.lock().await;
@@ -326,16 +326,17 @@ pub async fn player_recover_stream(player: State<'_, PlayerStateWrapper>) -> Res
}
}
// ===== HTML5 video state-report commands =====
// ===== Webview media 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
// Where media renders in the webview — the `<audio>` element of the webview
// audio backend, on a desktop with no mpv; video never does since DR-235 — the
// real player lives outside the native backend, so the frontend 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).
/// Report a webview media element's state change (playing/paused/loading/stopped/idle).
#[tauri::command]
#[specta::specta]
pub async fn player_report_state(
@@ -348,7 +349,7 @@ pub async fn player_report_state(
Ok(())
}
/// Report an HTML5 <video> position tick (seconds). The adapter should throttle
/// Report a webview media element's position tick (seconds). The adapter should throttle
/// these to roughly match the native backends' ~250ms cadence.
#[tauri::command]
#[specta::specta]
@@ -362,7 +363,7 @@ pub async fn player_report_position(
Ok(())
}
/// Report that the HTML5 <video> finished loading and knows its duration.
/// Report that a webview media element finished loading and knows its duration.
#[tauri::command]
#[specta::specta]
pub async fn player_report_media_loaded(