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
@@ -85,10 +85,9 @@ class MainActivity : TauriActivity() {
super.onWebViewCreate(webView)
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
mediaWebView = webView
// A new WebView means a new page, which reports no video yet. Anything the
// previous one left held would otherwise pin the screen on for the life of
// the process, since a page that goes away never sends its final
// setHtml5VideoState(false, …). (DR-202)
// A new WebView means a new page, which plays no video yet. Anything held
// for the previous one would otherwise pin the screen on for the life of
// the process. (DR-202)
ScreenWakeManager.releaseAll()
installJavascriptBridges(webView)
configureWebViewSettings(webView)
@@ -327,22 +326,6 @@ class MainActivity : TauriActivity() {
autoEnterPipEnabled = enabled
}
/**
* Report the WebView `<video>` state.
*
* Without this PiP only ever knew about the native ExoPlayer surface,
* which is behind an experimental flag that defaults to off — so in the
* shipping configuration nothing ever satisfied canEnterPip and the
* button did nothing. (DR-160)
*/
@JavascriptInterface
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
// The same report is what keeps the display awake on the webview
// rendering path — the WebView takes no display wake lock of its own
// for `<video>`. (DR-202)
ScreenWakeManager.onHtml5VideoState(active, playing)
}
}, "AndroidPictureInPicture")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
@@ -46,52 +46,7 @@ object PictureInPictureManager {
private var receiver: BroadcastReceiver? = null
private var hiddenWebView: WebView? = null
/**
* State of an HTML5 `<video>` playing inside the WebView, reported by the
* frontend.
*
* PiP was written for the native ExoPlayer surface only — [canEnterPip]
* required a SurfaceView to be attached and rendering. But native video is
* behind `experimentalNativeVideo`, which defaults to **off**, so in the
* shipping configuration video plays in the WebView's `<video>` element and
* every one of those conditions is false. `enterPip` therefore always bailed
* with "no local video playing": PiP could not work at all, however the
* button was pressed.
*
* On this path the WebView *is* the video, which inverts two things: the
* WebView must stay visible in PiP rather than be hidden, and play/pause has
* to reach the element rather than ExoPlayer. Both are handled below.
*
* TRACES: UR-041 | DR-160
*/
@Volatile
private var html5VideoActive = false
@Volatile
private var html5VideoPlaying = false
@Volatile
private var html5AspectRatio: Rational? = null
/**
* Report the WebView `<video>` state from the frontend.
*
* @param active whether a video element is currently the playback surface
* @param width intrinsic video width, for the PiP window's aspect ratio
* @param height intrinsic video height
* @param playing whether it is playing right now, for the PiP play/pause action
*/
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
html5VideoActive = active
html5VideoPlaying = playing
html5AspectRatio = if (active && width > 0 && height > 0) {
clampedRatio(width.toDouble() / height.toDouble())
} else {
null
}
}
/** True when PiP would be showing the native surface rather than the WebView. */
/** True when a native video surface is attached and playing. */
private fun isNativeVideoPath(): Boolean = try {
val player = JellyTauPlayer.getInstance()
player.isPlayingVideo() &&
@@ -120,10 +75,9 @@ object PictureInPictureManager {
*/
fun canEnterPip(activity: Activity): Boolean {
if (!isPipSupported(activity)) return false
// Either surface will do: the native one, or the WebView's `<video>`,
// which is what actually plays while experimentalNativeVideo is off.
// (DR-160)
return isNativeVideoPath() || html5VideoActive
// Video only ever renders on the native surface: the WebView `<video>`
// path is gone (DR-235). (DR-160)
return isNativeVideoPath()
}
/**
@@ -186,9 +140,7 @@ object PictureInPictureManager {
return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
}
// No native surface: the WebView is the video, so use the intrinsic size
// the frontend reported. (DR-160)
return html5AspectRatio
return null
}
/**
@@ -207,16 +159,10 @@ object PictureInPictureManager {
@RequiresApi(Build.VERSION_CODES.O)
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
// On the HTML5 path ExoPlayer is idle, so its `isPlaying` is always false
// and the button would be stuck showing "Play" mid-playback. (DR-160)
val isPlaying = if (isNativeVideoPath()) {
try {
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
} catch (e: Exception) {
false
}
} else {
html5VideoPlaying
val isPlaying = try {
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
} catch (e: Exception) {
false
}
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
@@ -288,11 +234,8 @@ object PictureInPictureManager {
*/
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
if (isInPipMode) {
// Hiding the WebView is correct only when the video is *behind* it on
// the native surface. On the HTML5 path the WebView is the video, so
// hiding it would leave an empty black PiP window — the frontend
// instead strips its own chrome when it hears the event below.
// (DR-160)
// The video is *behind* the WebView on the native surface, so hiding
// the WebView leaves only the picture. (DR-160)
if (isNativeVideoPath()) {
hideWebView(activity)
}
@@ -315,9 +258,8 @@ object PictureInPictureManager {
/**
* Fire a DOM event into the WebView.
*
* The HTML5 PiP path is a conversation with the frontend rather than
* something native can do alone: it has to be told to strip its chrome when
* the window shrinks, and to play/pause the element. (DR-160)
* Tells the frontend the window shrank or grew, so it can strip or restore
* its chrome. (DR-160)
*/
private fun dispatchWebEvent(activity: Activity, name: String) {
val webView = findWebView(activity.window.decorView) ?: return
@@ -358,28 +300,14 @@ object PictureInPictureManager {
if (intent?.action != ACTION_MEDIA_CONTROL) return
val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
if (isNativeVideoPath()) {
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
return
}
when (control) {
CONTROL_PLAY -> player.play()
CONTROL_PAUSE -> player.pause()
}
} else {
// The WebView owns playback here, so the command has to reach
// the `<video>` element. Driving ExoPlayer instead would do
// nothing at all, which is what a PiP button on the HTML5 path
// used to do. (DR-160)
val name = when (control) {
CONTROL_PLAY -> "jellytau-pip-play"
CONTROL_PAUSE -> "jellytau-pip-pause"
else -> return
}
dispatchWebEvent(activity, name)
html5VideoPlaying = control == CONTROL_PLAY
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
return
}
when (control) {
CONTROL_PLAY -> player.play()
CONTROL_PAUSE -> player.pause()
}
// Swap the button to reflect the new state.
updatePipActions(activity)
@@ -7,14 +7,12 @@ import android.view.WindowManager
import java.lang.ref.WeakReference
/**
* Which playback paths currently want the screen kept awake.
* Whether playback currently wants the screen kept awake.
*
* Pure state, deliberately free of any Android type so it can be unit-tested —
* see ScreenWakeStateTest. Two independent holders, because video can be
* rendered by either renderer and only one of them is active at a time:
*
* - **native** — ExoPlayer drawing into the TextureView (DR-192)
* - **html5** — a `<video>` inside the WebView, reported by the frontend
* see ScreenWakeStateTest. The one holder is ExoPlayer drawing video into the
* TextureView (DR-192); the WebView `<video>` that was a second holder is gone
* (DR-235).
*
* Audio is deliberately *not* a holder. Playing music with the screen off is the
* point of the audio path; only video needs the display alive.
@@ -23,11 +21,10 @@ import java.lang.ref.WeakReference
*/
class ScreenWakeState {
private var nativeVideoPlaying = false
private var html5VideoPlaying = false
/** True while any video renderer is actively playing. */
/** True while video is actively playing. */
val keepScreenOn: Boolean
get() = nativeVideoPlaying || html5VideoPlaying
get() = nativeVideoPlaying
/**
* @param playing whether ExoPlayer is playing right now
@@ -37,18 +34,9 @@ class ScreenWakeState {
nativeVideoPlaying = playing && isVideo
}
/**
* @param active whether a webview `<video>` is the current playback surface
* @param playing whether that element is playing right now
*/
fun updateHtml5(active: Boolean, playing: Boolean) {
html5VideoPlaying = active && playing
}
/** Drop every hold (teardown, or a page that can no longer be trusted). */
fun reset() {
nativeVideoPlaying = false
html5VideoPlaying = false
}
}
@@ -66,9 +54,7 @@ class ScreenWakeState {
* appeared nowhere, and neither renderer supplies one for free — ExoPlayer's
* `setWakeMode` is a *CPU/wifi* wake lock and says nothing about the display,
* and it draws into a `TextureView` we own rather than a `PlayerView`, which is
* the media3 widget that would otherwise set `keepScreenOn` itself. The WebView
* `<video>` path does not either: the display wake lock Chrome takes for video
* lives in the browser layer, not in an embedded WebView.
* the media3 widget that would otherwise set `keepScreenOn` itself.
*
* ## Approach
*
@@ -78,15 +64,9 @@ class ScreenWakeState {
* missed release the way an explicitly acquired wake lock can. It needs no
* permission. (The manifest's `WAKE_LOCK` is the media service's, unrelated.)
*
* The two renderers report independently and are OR-ed together in
* [ScreenWakeState]:
*
* - `JellyTauPlayer.onIsPlayingChanged` and its surface teardown drive the
* native path — ExoPlayer is the authoritative source of playback state, so
* the hold follows what it reports rather than what the UI intends.
* - `MainActivity`'s `AndroidPictureInPicture.setHtml5VideoState` bridge drives
* the webview path. The frontend already reports that state on every
* play/pause and on player teardown for PiP, so no new bridge is needed.
* `JellyTauPlayer.onIsPlayingChanged` and its surface teardown drive
* [ScreenWakeState] — ExoPlayer is the authoritative source of playback state,
* so the hold follows what it reports rather than what the UI intends.
*
* The Activity reference is weak and re-set on every `onCreate`, so a
* recreation (rotation) re-applies the current hold to the new window.
@@ -126,20 +106,9 @@ object ScreenWakeManager {
}
/**
* The frontend reported the webview `<video>` state. Arrives on a WebView
* binder thread, hence the synchronization and the post to the main thread.
*/
@Synchronized
fun onHtml5VideoState(active: Boolean, playing: Boolean) {
state.updateHtml5(active, playing)
apply()
}
/**
* Drop every hold. Used when a new WebView/page load invalidates whatever the
* previous page last reported — a page that goes away without a final
* `setHtml5VideoState(false, …)` would otherwise leave the screen pinned on
* for the life of the process.
* Drop every hold. Used when a new WebView/page load starts from scratch, so
* nothing held for the previous page can pin the screen on for the life of
* the process.
*/
@Synchronized
fun releaseAll() {
@@ -40,46 +40,9 @@ class ScreenWakeStateTest {
}
@Test
fun `webview video playing holds the screen on`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
assertTrue(state.keepScreenOn)
}
@Test
fun `webview video paused releases the screen`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = true, playing = false)
assertFalse(state.keepScreenOn)
}
/** The element going away must release even if it never reported a pause. */
@Test
fun `webview video going inactive while playing releases the screen`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = false, playing = true)
assertFalse(state.keepScreenOn)
}
/** The two rendering paths are independent holders; either one is enough. */
@Test
fun `one path releasing does not release while the other still plays`() {
fun `teardown releases the hold`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true)
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = false, playing = false)
assertTrue(state.keepScreenOn)
state.updateNative(playing = false, isVideo = true)
assertFalse(state.keepScreenOn)
}
@Test
fun `teardown releases both paths`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true)
state.updateHtml5(active = true, playing = true)
state.reset()
assertFalse(state.keepScreenOn)
}
+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(
-87
View File
@@ -1102,86 +1102,6 @@ fn specta_builder() -> Builder<tauri::Wry> {
])
}
/// Configure GStreamer (the media backend behind WebKitGTK's HTML5 `<video>`
/// element on Linux) to prefer hardware-accelerated VAAPI decoding when the
/// host provides it, falling back to software decoding otherwise.
///
/// All variables are only set if the user has not already exported them, so an
/// explicit override (e.g. forcing software decode for debugging) is respected.
/// They must be applied before WebKitGTK builds its GStreamer pipeline, hence the
/// call at the very top of `run()`.
#[cfg(target_os = "linux")]
fn enable_linux_hardware_video_decoding() {
// Boost the rank of the modern stateless VAAPI decoders (gst-plugins-bad
// `va` plugin) so GStreamer selects them ahead of the software decoders. The
// `MAX` rank wins decoder autoplugging when the hardware/driver supports the
// codec; unsupported codecs simply fall through to software.
let rank_overrides = "vah264dec:MAX,vah265dec:MAX,vavp9dec:MAX,vaav1dec:MAX,\
vampeg2dec:MAX,vavp8dec:MAX";
set_env_if_unset("GST_PLUGIN_FEATURE_RANK", rank_overrides);
// Ensure WebKit keeps GStreamer's hardware/DMABUF video path enabled. Setting
// this to "0" would force software decoding, so only default it to "1".
set_env_if_unset("WEBKIT_GST_ENABLE_HW_VIDEO_DECODER", "1");
info!("[INIT] Linux hardware video decoding (VAAPI) enabled where supported");
log_available_vaapi_decoders();
}
/// Probe (via `gst-inspect-1.0`, which ships with GStreamer) which VAAPI hardware
/// video decoders GStreamer can actually load on this host, and log the result so
/// it is clear at startup whether hardware decoding is genuinely available or
/// whether playback will fall back to software.
#[cfg(target_os = "linux")]
fn log_available_vaapi_decoders() {
const HW_DECODERS: &[&str] = &[
"vah264dec",
"vah265dec",
"vavp9dec",
"vaav1dec",
"vampeg2dec",
"vavp8dec",
];
let available: Vec<&str> = HW_DECODERS
.iter()
.copied()
.filter(|name| {
std::process::Command::new("gst-inspect-1.0")
.arg(name)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
})
.collect();
if available.is_empty() {
log::warn!(
"[INIT] No VAAPI hardware video decoders found via gst-inspect-1.0; \
video will use software decoding. Install the GStreamer 'va' plugin \
(gst-plugins-bad) and a VAAPI driver to enable hardware decoding."
);
} else {
info!(
"[INIT] VAAPI hardware video decoders available to GStreamer: {}",
available.join(", ")
);
}
}
#[cfg(target_os = "linux")]
fn set_env_if_unset(key: &str, value: &str) {
if std::env::var_os(key).is_none() {
// SAFETY: called once at startup before any threads that read the
// environment (WebKitGTK/GStreamer) are spawned.
std::env::set_var(key, value);
}
}
/// Cached thumbnails are handed to the webview as asset-protocol URLs by
/// `convertFileSrc` (`asset://localhost/…` on Linux/macOS,
/// `http://asset.localhost/…` on Windows/Android). Tauri only answers that
@@ -1261,13 +1181,6 @@ pub fn run() {
// TRACES: UR-078 | DR-218
crate::utils::diagnostics::install_panic_hook();
// On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
// GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
// when available so video transcoding/decoding does not fall back to the CPU.
// These must be set before WebKitGTK initializes its GStreamer pipeline.
#[cfg(target_os = "linux")]
enable_linux_hardware_video_decoding();
// NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app
+6 -8
View File
@@ -98,10 +98,9 @@ pub trait PlayerBackend: Send + Sync {
/// Set the active audio track by stream index
///
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
/// does **not** override it — MPV is the audio-only backend here, so it keeps
/// this `not_implemented()` default and the Linux video path switches track by
/// re-opening the stream instead (`player_switch_audio_track`).
/// Overridden by both video backends, ExoPlayer and `MpvBackend`; the
/// argument is a position among the file's audio tracks. A transcode is
/// re-opened instead (`player_switch_audio_track`).
///
/// TRACES: UR-021 | IR-019, DR-024
fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> {
@@ -111,10 +110,9 @@ pub trait PlayerBackend: Send + Sync {
/// Set the active subtitle track by stream index (None to disable subtitles)
///
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
/// does **not** override it, so it keeps this `not_implemented()` default;
/// the Linux video path renders subtitles as `<track>` children of the
/// WebKitGTK HTML5 `<video>` element and never calls this.
/// Overridden by both video backends, ExoPlayer and `MpvBackend`; the
/// argument is a position in the sideloaded subtitle list the play request
/// carried.
///
/// TRACES: UR-020 | IR-018, DR-023
fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> {
+9 -10
View File
@@ -85,9 +85,8 @@ pub enum PlayerStatusEvent {
remaining_seconds: u32,
},
/// Time-based sleep timer expired: playback must stop. The backend stops
/// its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
/// webview outside the backend's control — the frontend pauses it on this
/// event.
/// its own (MPV/ExoPlayer) playback; the frontend pauses the active adapter
/// on this event, which reaches a webview `<audio>` element where one plays.
SleepTimerExpired,
/// Show next episode popup with countdown
ShowNextEpisodePopup {
@@ -152,9 +151,9 @@ pub enum PlayerStatusEvent {
/// media item locally), so the native side only signals intent here.
RemoteDisconnectRequested,
/// Backend-originated control command targeting the active frontend player
/// adapter (the HTML5 <video> that lives in the webview, which Rust cannot
/// drive directly). Emitted by control paths like the sleep timer, lockscreen,
/// or remote so they can pause/play/seek/stop the webview element.
/// adapter — the webview `<audio>` element, which Rust cannot drive
/// directly. Emitted by control paths like the sleep timer, lockscreen, or
/// remote so they can pause/play/seek/stop it.
/// `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
ControlCommand {
/// One of: "play", "pause", "stop", "seek".
@@ -164,10 +163,10 @@ pub enum PlayerStatusEvent {
},
/// Ask the frontend webview `<audio>` element to load and play a stream.
///
/// Emitted by `WebviewAudioBackend` on platforms with no native audio
/// backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
/// element in the webview, mirroring how all video already renders through
/// the webview `<video>`. The element then reports its state/position back
/// Emitted by `WebviewAudioBackend` on a desktop with no native audio
/// backend (none that ships: Linux and Windows have mpv): audio-only
/// playback is rendered by an `<audio>` element in the webview. The element
/// then reports its state/position back
/// through the `player_report_*` commands, so the Rust controller stays the
/// single source of truth. Subsequent play/pause/seek/stop reach the element
/// via `ControlCommand`.
+4
View File
@@ -0,0 +1,4 @@
WEBVTT
00:00.000 --> 00:02.000
fixture subtitle
Binary file not shown.
+5 -18
View File
@@ -140,7 +140,7 @@ pub struct Capabilities {
pub audio_track_switching: bool,
/// A *server-side transcode* can be seeked without re-opening the stream.
///
/// True for hls.js, which seeks within the VOD playlist it is handed and
/// True for ExoPlayer, which seeks within the VOD playlist it is handed and
/// lets the server catch up. False for mpv, whose HLS demuxer cannot make
/// the server transcode from a new offset.
///
@@ -173,9 +173,8 @@ impl Capabilities {
/// ExoPlayer.
///
/// **Can** seek a transcode in place. It is a full HLS client, so like
/// hls.js it seeks within the VOD playlist it was handed and lets the
/// server catch up. Grouping it with mpv as "a native engine" gets this
/// **Can** seek a transcode in place. It is a full HLS client, so it seeks
/// within the VOD playlist it was handed and lets the server catch up. Grouping it with mpv as "a native engine" gets this
/// exactly backwards — being native is not the property that matters here,
/// speaking HLS is, and that is the whole reason this is declared per
/// engine rather than inferred from a category.
@@ -188,18 +187,6 @@ impl Capabilities {
seeks_transcoded_in_place: true,
}
}
/// An engine that renders through the webview element, where hls.js seeks
/// within the playlist it was handed.
pub fn webview() -> Self {
Self {
video: true,
audio_settings: false,
subtitle_switching: true,
audio_track_switching: false,
seeks_transcoded_in_place: true,
}
}
}
/// A request to present an item.
@@ -242,7 +229,7 @@ impl OpenRequest {
/// Anything that can present media.
///
/// Implementations: `MpvPlayer` (Linux/Windows), `ExoPlayerPlayer` (Android),
/// `WebviewPlayer` (HTML5 element), and `FakePlayer` for tests. Every one of
/// and `FakePlayer` for tests. Every one of
/// them must pass [`super::conformance`].
pub trait MediaPlayer: Send {
/// Present `req.selection`, beginning at `req.start`.
@@ -267,7 +254,7 @@ pub trait MediaPlayer: Send {
/// Seek to an absolute position on the item's own timeline.
///
/// Whether that is an in-place seek or a re-open of the stream is the
/// engine's business: hls.js seeks within a VOD playlist, mpv's HLS demuxer
/// engine's business: ExoPlayer seeks within a VOD playlist, mpv's HLS demuxer
/// cannot make a server transcode from a new offset. Callers state the
/// destination and nothing else.
fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
+12 -39
View File
@@ -19,6 +19,8 @@ pub mod media_player;
pub mod mpv_command;
#[cfg(target_os = "linux")]
pub mod mpv_player;
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub mod mpv_tracks;
pub mod queue;
pub mod seek;
pub mod session;
@@ -322,9 +324,10 @@ pub struct PlayerController {
background_audio_base: Arc<Mutex<f64>>,
// True while a background-audio handoff owns playback: the native audio
// player is the real player and the webview <video> has been torn down.
// player is the real player and the video has been replaced.
//
// The teardown is what makes this necessary. It fires a DOM `pause` that the
// The teardown is what made this necessary (when a webview <video> was
// torn down). It fires a DOM `pause` that the
// frontend reports like any other, which would otherwise leave the controller
// believing webview media is still active — aiming lockscreen transport at an
// element that no longer exists (see `is_html5_active`).
@@ -341,7 +344,8 @@ pub struct PlayerController {
// TRACES: UR-040 | DR-129
stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
// Last state reported by a webview-rendered `<audio>` element (the webview
// audio backend; video never renders in the webview since DR-235).
//
// Webview-rendered media is played by an element the native backend cannot
// reach, so the backend's own state() says nothing about it. Tracking the
@@ -421,7 +425,7 @@ impl PlayerController {
/// Configure the media repository used for next-episode lookups.
///
/// The Android ExoPlayer ended-callback calls `on_playback_ended` with no
/// repository handle (unlike the Linux HTML5 path, which passes one per
/// repository handle (unlike a frontend-reported end, which passes one per
/// call), so the controller needs a repository of its own or episode
/// autoplay silently decides Stop.
pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
@@ -541,37 +545,6 @@ impl PlayerController {
Ok(())
}
/// Set the current queue item without loading it into the playback backend.
///
/// Used on platforms where video is rendered outside the native backend
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
/// item, but MPV must not start a redundant decode for it.
///
/// Not gated to Linux. Its caller stopped being a `#[cfg]` branch and became
/// a runtime question — "does this renderer draw the picture?" — so the
/// `else` arm is compiled on every platform even where it never runs. The
/// gate outliving its caller broke the Android build outright, which went
/// unnoticed because nothing built for Android afterwards.
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
debug!(
"[PlayerController] set_current_item (no backend load): {}",
item.title
);
self.reset_autoplay_count();
// A different item is current; the last one's reported position must not
// be reported against it. This path is how webview-rendered video is
// queued (no backend load at all), so it is exactly where a stale
// reading would otherwise survive.
// TRACES: UR-005 | DR-178
self.clear_reported_time();
let mut queue = self.queue.lock_safe();
queue.set_queue(vec![item], 0);
Ok(())
}
/// Load and play an item without modifying the queue
/// Use this when the queue is already set up and you just want to play a specific item from it
pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
@@ -596,7 +569,7 @@ impl PlayerController {
// intermittent.
//
// The webview re-establishes its own authority the moment an element
// reports again, so nothing is lost on the HTML5 path: this is the same
// reports again, so nothing is lost on the webview path: this is the same
// "element is gone" semantics as the "stopped"/"idle" report, applied at
// the point where we can know it directly.
//
@@ -720,7 +693,7 @@ impl PlayerController {
Ok(())
}
/// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real
/// True while webview-rendered media (a webview `<audio>`) is the real
/// player, so transport must be routed to it rather than the native backend.
///
/// TRACES: UR-005 | DR-097
@@ -772,7 +745,7 @@ impl PlayerController {
/// Toggle play/pause.
///
/// The decision is made HERE, from authoritative state — the reported webview
/// state for HTML5-rendered media, or the native backend's state otherwise.
/// state for webview-rendered audio, or the native backend's state otherwise.
/// The frontend must never decide this from the DOM (see DR-097).
///
/// TRACES: UR-005 | DR-097
@@ -1049,7 +1022,7 @@ impl PlayerController {
/// truncation comparison. `position()` alone answers for exactly one of the
/// three ways this app plays media, and reads 0 for the other two:
///
/// - **Webview `<video>`/`<audio>`**: nothing is loaded into the native
/// - **Webview `<audio>`**: nothing is loaded into the native
/// backend, so its position is a permanent 0. The element's own reports are
/// the only reading there is.
/// - **Background-audio handoff**: the audio-only stream's zero is the
+41 -2
View File
@@ -384,8 +384,8 @@ impl MpvBackend {
// StateChanged rather than tracking playback itself, per the
// one-directional state rule. Unobserved, the event never came and
// the button never moved. Invisible until native video shipped,
// because the webview <video> element's own DOM events drove that
// control on Linux.
// because the (since deleted) webview <video> element's own DOM
// events drove that control on Linux.
//
// TRACES: UR-005 | DR-239
ev_ctx
@@ -696,6 +696,15 @@ impl PlayerBackend for MpvBackend {
// TRACES: UR-040, UR-005 | DR-253
*self.pending_seek.lock_safe() = None;
// The item's own sideloaded subtitles, none shown, and its default audio
// track — whatever the previous item had chosen. Only video carries
// subtitles; for audio this just clears the last item's.
// TRACES: UR-020, UR-021 | DR-023, DR-024, DR-235
let subtitle_urls: Vec<&str> = media.subtitles.iter().map(|t| t.url.as_str()).collect();
super::mpv_tracks::prepare_load(&self.mpv, &subtitle_urls).map_err(|e| PlayerError {
message: format!("Failed to prepare tracks: {e}"),
})?;
// Load the media file. Through `mpv_command::command`, never
// `Mpv::command`: the URL carries server-controlled text.
// TRACES: UR-003, UR-004 | DR-298
@@ -858,6 +867,36 @@ impl PlayerBackend for MpvBackend {
state.volume
}
/// `stream_index` is a *position*: the n-th audio track of the file, the
/// same meaning ExoPlayer gives it (`player_switch_audio_track` passes the
/// array index). Only reached for a direct play/stream — a transcode carries
/// one track and is re-opened instead.
///
/// TRACES: UR-021 | IR-019, DR-024, DR-235
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
let position = usize::try_from(stream_index).map_err(|_| PlayerError {
message: format!("Invalid audio track position {stream_index}"),
})?;
super::mpv_tracks::select_audio(&self.mpv, position)
.map_err(|message| PlayerError { message })
}
/// `stream_index` is the position in the sideloaded subtitle list the play
/// request carried (`nativeSubtitleArrayIndex`), `None` to hide subtitles.
///
/// TRACES: UR-020 | IR-018, DR-023, DR-235
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
let position = stream_index
.map(|i| {
usize::try_from(i).map_err(|_| PlayerError {
message: format!("Invalid subtitle position {i}"),
})
})
.transpose()?;
super::mpv_tracks::select_subtitle(&self.mpv, position)
.map_err(|message| PlayerError { message })
}
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
info!("[MpvBackend] Applying audio settings");
self.audio_settings = settings.clone();
+257
View File
@@ -0,0 +1,257 @@
//! Subtitle and audio-track selection on mpv, by *position*, the way the
//! frontend asks for it.
//!
//! Until DR-235 mpv never drew video, so it never had to: subtitles were
//! `<track>` children of the webview `<video>` and an audio track change
//! re-opened the stream. With mpv the only desktop video renderer, it has to
//! answer the same two calls ExoPlayer does, with the same meaning:
//!
//! - **Subtitles** are the sideloaded WebVTT list the play request carries
//! (`MediaItem::subtitles`), and `set_subtitle_track(n)` selects the *n-th of
//! those* — the position the frontend computes with `nativeSubtitleArrayIndex`.
//! They reach mpv as external files queued on `sub-files` before the load, and
//! selection starts off, because the menu opens on "Off".
//! - **Audio** `set_audio_track(n)` selects the n-th audio track of the file —
//! the position in the item's audio streams, which is file order. Only a direct
//! play/stream carries every track; a transcode is re-opened instead
//! (`AudioTrackSwitchStrategy`).
//!
//! mpv's own track ids are not positions: they count every track of a type,
//! embedded before external, from 1. So a position is always resolved against
//! the live `track-list`.
//!
//! TRACES: UR-020, UR-021 | DR-023, DR-024, DR-235
use libmpv::Mpv;
use super::mpv_command;
/// One entry of mpv's `track-list`, reduced to what selection needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackInfo {
pub id: i64,
/// "video", "audio" or "sub".
pub kind: String,
pub external: bool,
}
/// The mpv id of the `position`-th track of `kind` (optionally only external or
/// only embedded ones), in `track-list` order.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
pub fn nth_track_id(
tracks: &[TrackInfo],
kind: &str,
external: Option<bool>,
position: usize,
) -> Option<i64> {
tracks
.iter()
.filter(|t| t.kind == kind && external.is_none_or(|e| t.external == e))
.nth(position)
.map(|t| t.id)
}
/// Read the current `track-list`.
pub fn track_list(mpv: &Mpv) -> Vec<TrackInfo> {
let count: i64 = mpv.get_property("track-list/count").unwrap_or(0);
(0..count)
.filter_map(|i| {
let id: i64 = mpv.get_property(&format!("track-list/{i}/id")).ok()?;
let kind: String = mpv.get_property(&format!("track-list/{i}/type")).ok()?;
let external: bool = mpv
.get_property(&format!("track-list/{i}/external"))
.unwrap_or(false);
Some(TrackInfo { id, kind, external })
})
.collect()
}
/// Prepare the next `loadfile`: these subtitle files will be loaded with it, no
/// subtitle is shown, and the file's default audio track plays.
///
/// `sid`/`aid` set while idle become the options the next file opens with, so
/// a track chosen for the previous item cannot leak into this one.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
pub fn prepare_load(mpv: &Mpv, subtitle_urls: &[&str]) -> Result<(), String> {
mpv_command::command(mpv, &["change-list", "sub-files", "clr", ""])?;
for url in subtitle_urls {
// `append` adds one item without splitting on the list separator, which
// a URL's `:` would otherwise trip. Through the argv form (DR-298), so the
// URL is never parsed as command text.
mpv_command::command(mpv, &["change-list", "sub-files", "append", url])?;
}
mpv.set_property("sid", "no")
.map_err(|e| format!("could not set sid=no: {e:?}"))?;
mpv.set_property("aid", "auto")
.map_err(|e| format!("could not set aid=auto: {e:?}"))?;
Ok(())
}
/// Show the `position`-th sideloaded subtitle, or none.
///
/// TRACES: UR-020 | DR-023 | UT-275
pub fn select_subtitle(mpv: &Mpv, position: Option<usize>) -> Result<(), String> {
let Some(position) = position else {
return mpv
.set_property("sid", "no")
.map_err(|e| format!("could not set sid=no: {e:?}"));
};
let id = nth_track_id(&track_list(mpv), "sub", Some(true), position)
.ok_or_else(|| format!("no sideloaded subtitle at position {position}"))?;
mpv.set_property("sid", id)
.map_err(|e| format!("could not set sid={id}: {e:?}"))
}
/// Play the `position`-th audio track of the file.
///
/// TRACES: UR-021 | DR-024 | UT-275
pub fn select_audio(mpv: &Mpv, position: usize) -> Result<(), String> {
let id = nth_track_id(&track_list(mpv), "audio", Some(false), position)
.ok_or_else(|| format!("no audio track at position {position}"))?;
mpv.set_property("aid", id)
.map_err(|e| format!("could not set aid={id}: {e:?}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
fn fixture(name: &str) -> String {
format!("{}/src/player/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
}
fn track(id: i64, kind: &str, external: bool) -> TrackInfo {
TrackInfo {
id,
kind: kind.to_string(),
external,
}
}
/// mpv numbers each type from 1, embedded before external, so a position in
/// the sideloaded list is not an id. The file here has two embedded
/// subtitles; the first sideloaded one is mpv's sub 3.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
#[test]
fn positions_resolve_to_mpv_ids_per_kind() {
let tracks = [
track(1, "video", false),
track(1, "audio", false),
track(2, "audio", false),
track(1, "sub", false),
track(2, "sub", false),
track(3, "sub", true),
track(4, "sub", true),
];
assert_eq!(nth_track_id(&tracks, "sub", Some(true), 0), Some(3));
assert_eq!(nth_track_id(&tracks, "sub", Some(true), 1), Some(4));
assert_eq!(nth_track_id(&tracks, "sub", Some(true), 2), None);
assert_eq!(nth_track_id(&tracks, "audio", Some(false), 1), Some(2));
assert_eq!(nth_track_id(&tracks, "audio", None, 0), Some(1));
}
fn loaded_mpv(subs: &[&str]) -> Mpv {
let mpv = Mpv::new().expect("libmpv must be available to run the player tests");
mpv.set_property("ao", "null").unwrap();
mpv.set_property("vo", "null").unwrap();
mpv.set_property("pause", true).unwrap();
prepare_load(&mpv, subs).unwrap();
mpv_command::command(&mpv, &["loadfile", &fixture("two-audio-tracks.mkv")]).unwrap();
// Video, two audio tracks, and one track per sideloaded subtitle.
let expected = 3 + subs.len() as i64;
let deadline = Instant::now() + Duration::from_secs(10);
while mpv.get_property::<i64>("track-list/count").unwrap_or(0) < expected {
assert!(
Instant::now() < deadline,
"the fixture never finished loading"
);
std::thread::sleep(Duration::from_millis(20));
}
mpv
}
/// Against a real file: the sideloaded subtitle arrives, starts hidden, and
/// is shown and hidden by position.
///
/// TRACES: UR-020 | DR-023 | UT-275
#[test]
fn a_sideloaded_subtitle_starts_off_and_is_selected_by_position() {
let vtt = fixture("sub.vtt");
let mpv = loaded_mpv(&[&vtt]);
assert_eq!(mpv.get_property::<String>("sid").unwrap(), "no");
select_subtitle(&mpv, Some(0)).unwrap();
let expected = nth_track_id(&track_list(&mpv), "sub", Some(true), 0).unwrap();
assert_eq!(mpv.get_property::<i64>("sid").unwrap(), expected);
select_subtitle(&mpv, None).unwrap();
assert_eq!(mpv.get_property::<String>("sid").unwrap(), "no");
assert!(select_subtitle(&mpv, Some(5)).is_err());
}
/// Against a real file with two audio tracks: position 1 is the second one.
///
/// TRACES: UR-021 | DR-024 | UT-275
#[test]
fn an_audio_track_is_selected_by_position_in_the_file() {
let mpv = loaded_mpv(&[]);
select_audio(&mpv, 1).unwrap();
assert_eq!(mpv.get_property::<i64>("aid").unwrap(), 2);
select_audio(&mpv, 0).unwrap();
assert_eq!(mpv.get_property::<i64>("aid").unwrap(), 1);
assert!(select_audio(&mpv, 2).is_err());
}
/// The next item opens with no subtitle and its own default audio, whatever
/// the last one had chosen, and with only its own subtitle files.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
#[test]
fn preparing_a_load_forgets_the_previous_items_choices() {
let vtt = fixture("sub.vtt");
let mpv = loaded_mpv(&[&vtt]);
select_subtitle(&mpv, Some(0)).unwrap();
select_audio(&mpv, 1).unwrap();
// The next item: no subtitles of its own this time.
prepare_load(&mpv, &[]).unwrap();
mpv_command::command(
&mpv,
&["loadfile", &fixture("two-audio-tracks.mkv"), "replace"],
)
.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let tracks = track_list(&mpv);
let settled = tracks.len() == 3 && mpv.get_property::<i64>("aid").is_ok();
if settled {
break;
}
assert!(
Instant::now() < deadline,
"the second load never settled: {tracks:?}"
);
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(mpv.get_property::<String>("sid").unwrap(), "no");
assert_eq!(
mpv.get_property::<i64>("aid").unwrap(),
1,
"default audio again"
);
assert_eq!(
nth_track_id(&track_list(&mpv), "sub", None, 0),
None,
"the previous item's subtitle file came along"
);
}
}
+1 -2
View File
@@ -2,8 +2,7 @@
//!
//! Three things need this and must agree: the mpv backend (which has to be
//! configured for video *at construction*, before anything plays), the video
//! surface (which has nothing to draw otherwise), and `get_player_status`
//! (which tells the frontend whether to use a webview `<video>` element).
//! surface (which has nothing to draw otherwise), and the device profile.
//!
//! It is a function rather than three `env::var` checks for the reason this
//! codebase keeps rediscovering: a capability answered in several places is a
+40 -129
View File
@@ -5,17 +5,18 @@
//! [`VideoSeekStrategy`] into a concrete backend/frontend action.
/// Seek strategy for video playback, derived from a stream's characteristics.
///
/// Every video renderer is a native backend (mpv, ExoPlayer) since the webview
/// `<video>` path was deleted (DR-235), so the backend performs every seek; what
/// remains to decide is whether it can move the stream in place.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoSeekStrategy {
/// Local file - always use native seek on backend
LocalNativeSeek,
/// HLS or direct stream with HTML5 - frontend handles seek, skip backend
Html5NativeSeek,
/// HLS or direct stream with native backend - backend handles seek
/// Seekable where it sits (direct play/stream, or a transcode the engine
/// can move) - backend seeks
BackendNativeSeek,
/// Transcoded non-HLS with HTML5 - reload stream, frontend handles
Html5ReloadStream,
/// Transcoded non-HLS with native backend - reload stream, backend handles
/// A transcode the engine cannot move - re-open the stream at the target
BackendReloadStream,
}
@@ -29,12 +30,10 @@ pub enum VideoSeekStrategy {
/// can seek a server-side transcode without re-opening it. Declared by the
/// engine via `Capabilities`, never inferred from the URL or the renderer.
/// * `needs_transcoding` - Whether the content needs transcoding
/// * `use_html5` - Whether frontend is using HTML5 video element
pub fn determine_video_seek_strategy(
is_local: bool,
seeks_transcoded_in_place: bool,
needs_transcoding: bool,
use_html5: bool,
) -> VideoSeekStrategy {
// Local files always support native seeking via backend
if is_local {
@@ -42,39 +41,20 @@ pub fn determine_video_seek_strategy(
}
// A server-side transcode is produced *from* `StartTimeTicks`, so where the
// seek lands is a property of the request, not of the stream in hand.
//
// hls.js is the exception: handed a VOD playlist it seeks within it and lets
// the server catch up segment by segment. mpv's HLS demuxer cannot make
// Jellyfin transcode from a new offset, so for the native backend a
// seek lands is a property of the request, not of the stream in hand. mpv's
// HLS demuxer cannot make Jellyfin transcode from a new offset, so for it a
// transcoded seek must re-negotiate the stream regardless of container.
//
// Before native video shipped, `use_html5` was always true for HLS and the
// native+HLS+transcode cell was unreachable, which is why `is_hls` alone
// used to be a safe proxy for "seekable in place". It no longer is: turning
// native video on routed every transcoded seek into a backend seek that
// silently does nothing, and presents as "resume does not work".
if needs_transcoding {
// Whether a transcode can be seeked in place is a property of the
// engine, and the engine states it. This used to be inferred from
// `is_hls`, which held only while hls.js was the sole HLS renderer —
// and stopped holding the moment mpv became one (DR-238).
return match (seeks_transcoded_in_place, use_html5) {
(true, true) => VideoSeekStrategy::Html5NativeSeek,
(true, false) => VideoSeekStrategy::BackendNativeSeek,
(false, true) => VideoSeekStrategy::Html5ReloadStream,
(false, false) => VideoSeekStrategy::BackendReloadStream,
};
// Whether a transcode can be seeked in place is a property of the engine,
// and the engine states it. This used to be inferred from `is_hls`, which
// held only while hls.js was the sole HLS renderer — and stopped holding the
// moment mpv became one (DR-238).
if needs_transcoding && !seeks_transcoded_in_place {
return VideoSeekStrategy::BackendReloadStream;
}
// Direct play and direct stream are seekable where they sit.
if use_html5 {
// The frontend seeks via videoElement.currentTime; calling backend.seek()
// would move a player that is not the one rendering.
VideoSeekStrategy::Html5NativeSeek
} else {
VideoSeekStrategy::BackendNativeSeek
}
// Direct play, direct stream, or a transcode the engine can move.
VideoSeekStrategy::BackendNativeSeek
}
// The four items below are consumed by the Android MediaSessionHandler; on other
@@ -224,114 +204,45 @@ mod tests {
#[test]
fn test_seek_strategy_local_file() {
// Local files always use native backend seek regardless of other flags
assert_eq!(
determine_video_seek_strategy(true, false, false, false),
VideoSeekStrategy::LocalNativeSeek
);
assert_eq!(
determine_video_seek_strategy(true, false, false, true),
VideoSeekStrategy::LocalNativeSeek
);
assert_eq!(
determine_video_seek_strategy(true, true, true, true),
VideoSeekStrategy::LocalNativeSeek
);
for (in_place, transcode) in [(false, false), (true, true), (false, true)] {
assert_eq!(
determine_video_seek_strategy(true, in_place, transcode),
VideoSeekStrategy::LocalNativeSeek
);
}
}
/// Non-transcoded streams seek in place regardless of the engine's
/// transcode ability, which only applies to transcodes.
/// Direct play and direct stream seek in place, whatever the engine's
/// transcode ability — that only applies to transcodes.
#[test]
fn test_seek_strategy_direct_stream() {
// HTML5 renders, so the frontend seeks the element
assert_eq!(
determine_video_seek_strategy(false, true, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// The native engine renders, so it seeks
assert_eq!(
determine_video_seek_strategy(false, true, false, false),
VideoSeekStrategy::BackendNativeSeek
);
// A transcode an engine says it can move: seek in place
assert_eq!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
);
for in_place in [false, true] {
assert_eq!(
determine_video_seek_strategy(false, in_place, false),
VideoSeekStrategy::BackendNativeSeek
);
}
}
/// A server-side transcode cannot be seeked by the native backend.
/// A server-side transcode is re-opened by an engine that cannot move it,
/// and seeked in place by one that says it can.
///
/// Jellyfin produces a transcode from `StartTimeTicks`; hls.js can seek
/// within the VOD playlist it is handed, but mpv's HLS demuxer cannot make
/// the server transcode from a new offset, so the stream has to be
/// re-negotiated. Before native video existed, `use_html5` was always true
/// for HLS and this case was unreachable — turning native video on routed
/// every transcoded seek into a native seek that silently does nothing,
/// which presents as "resume does not work".
/// Jellyfin produces a transcode from `StartTimeTicks`; mpv's HLS demuxer
/// cannot make the server transcode from a new offset, so the stream has to
/// be re-negotiated. Inferring this from the container once routed every
/// transcoded seek into a native seek that silently does nothing, which
/// presents as "resume does not work".
///
/// TRACES: UR-040 | DR-238, DR-246 | UT-217
#[test]
fn test_transcoded_seek_follows_the_engines_declared_ability() {
// An engine that cannot move a server-side transcode re-opens it,
// whichever side is rendering.
assert_eq!(
determine_video_seek_strategy(false, false, true, false),
determine_video_seek_strategy(false, false, true),
VideoSeekStrategy::BackendReloadStream
);
assert_eq!(
determine_video_seek_strategy(false, false, true, true),
VideoSeekStrategy::Html5ReloadStream
);
// hls.js can, and says so, so it seeks in place.
assert_eq!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
);
// The container the stream arrives in no longer decides anything: the
// same declared ability gives the same answer on the native side.
assert_eq!(
determine_video_seek_strategy(false, true, true, false),
determine_video_seek_strategy(false, true, true),
VideoSeekStrategy::BackendNativeSeek
);
}
/// Test video seek strategy for direct play (non-transcoded) streams
#[test]
fn test_seek_strategy_direct_play() {
// Direct play with HTML5 - frontend handles seek
assert_eq!(
determine_video_seek_strategy(false, false, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// Direct play with native backend - backend handles seek
assert_eq!(
determine_video_seek_strategy(false, false, false, false),
VideoSeekStrategy::BackendNativeSeek
);
}
/// Test video seek strategy for transcoded non-HLS streams
#[test]
fn test_seek_strategy_transcoded_non_hls() {
// Transcoded non-HLS with HTML5 - need to reload stream, frontend handles
assert_eq!(
determine_video_seek_strategy(false, false, true, true),
VideoSeekStrategy::Html5ReloadStream
);
// Transcoded non-HLS with native backend - need to reload stream, backend handles
assert_eq!(
determine_video_seek_strategy(false, false, true, false),
VideoSeekStrategy::BackendReloadStream
);
}
/// Test the specific bug fix: HLS + HTML5 should NOT call backend seek
/// This was the bug causing "Raw(-10)" errors
#[test]
fn test_hls_html5_does_not_use_backend_seek() {
let strategy = determine_video_seek_strategy(false, true, false, true);
// Should be Html5NativeSeek, NOT BackendNativeSeek
assert_eq!(strategy, VideoSeekStrategy::Html5NativeSeek);
assert_ne!(strategy, VideoSeekStrategy::BackendNativeSeek);
}
}
+4 -32
View File
@@ -13,10 +13,6 @@
/// How a request to change audio track has to be carried out.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioTrackSwitchStrategy {
/// Re-open the stream pinned to the chosen track; the frontend reloads its
/// `<video>` element. An HTML5 element cannot select an audio track at all,
/// so this holds whether or not the current stream is a transcode.
Html5ReloadStream,
/// Re-open the stream pinned to the chosen track; the backend reloads
/// itself and restores the position.
BackendReloadStream,
@@ -29,17 +25,9 @@ pub enum AudioTrackSwitchStrategy {
/// # Arguments
/// * `needs_transcoding` - Whether the stream now playing is a server-side
/// transcode, which carries exactly the one audio track it was built around.
/// * `use_html5` - Whether the frontend `<video>` element is rendering.
///
/// TRACES: UR-021 | IR-019, DR-024, DR-258 | UT-232
pub fn determine_audio_track_switch_strategy(
needs_transcoding: bool,
use_html5: bool,
) -> AudioTrackSwitchStrategy {
if use_html5 {
return AudioTrackSwitchStrategy::Html5ReloadStream;
}
pub fn determine_audio_track_switch_strategy(needs_transcoding: bool) -> AudioTrackSwitchStrategy {
if needs_transcoding {
AudioTrackSwitchStrategy::BackendReloadStream
} else {
@@ -86,8 +74,7 @@ mod tests {
assert_eq!(resume_position(None, 1337.5), 1337.5);
}
/// The HTML5 path does have an element and its clock is the honest answer
/// there, so what the caller supplies wins.
/// A caller that does know the position is believed.
#[test]
fn a_caller_that_knows_its_position_is_believed() {
assert_eq!(resume_position(Some(42.0), 1337.5), 42.0);
@@ -123,7 +110,7 @@ mod tests {
#[test]
fn a_transcode_is_re_opened_because_it_carries_only_one_track() {
assert_eq!(
determine_audio_track_switch_strategy(true, false),
determine_audio_track_switch_strategy(true),
AudioTrackSwitchStrategy::BackendReloadStream
);
}
@@ -133,23 +120,8 @@ mod tests {
#[test]
fn a_direct_play_switches_in_place() {
assert_eq!(
determine_audio_track_switch_strategy(false, false),
determine_audio_track_switch_strategy(false),
AudioTrackSwitchStrategy::BackendSelectInPlace
);
}
/// An HTML5 `<video>` element has no track-selection API, so it reloads
/// either way. This is the path that already worked, and it must keep
/// working: the fix is about the native side only.
#[test]
fn html5_always_reloads_because_the_element_cannot_select() {
assert_eq!(
determine_audio_track_switch_strategy(true, true),
AudioTrackSwitchStrategy::Html5ReloadStream
);
assert_eq!(
determine_audio_track_switch_strategy(false, true),
AudioTrackSwitchStrategy::Html5ReloadStream
);
}
}
+11 -18
View File
@@ -1,25 +1,18 @@
//! Webview audio backend — audio-only playback for platforms without a native
//! audio backend (currently Windows).
//! Webview audio backend — audio-only playback for a desktop without a native
//! audio backend. No shipped platform uses it: Linux and Windows play through
//! mpv, Android through ExoPlayer.
//!
//! ## Why this exists
//! All *video* already renders through the webview HTML5 `<video>` element on
//! every platform (see `VideoPlayer.svelte`); libmpv/ExoPlayer only ever drive
//! *audio-only* (music) playback. On Windows there is no native audio backend,
//! so `create_player_backend()` used to fall back to `NullBackend` and music was
//! silent.
//!
//! This backend fills that gap without any C dependency (so it still
//! cross-compiles from Linux): instead of decoding audio itself, it hands the
//! stream URL to a frontend `<audio>` element via a `WebviewAudioLoad` event and
//! then drives play/pause/seek/stop through `ControlCommand` events — exactly the
//! round-trip the HTML5 video path already uses. The `<audio>` element reports
//! its real state/position back through the `player_report_*` commands, so the
//! Rust `PlayerController` remains the single source of truth (the controller's
//! `report_html5_*` methods fold those reports into the normal event pipeline).
//! Instead of decoding audio itself, it hands the stream URL to a frontend
//! `<audio>` element via a `WebviewAudioLoad` event and then drives
//! play/pause/seek/stop through `ControlCommand` events. The `<audio>` element
//! reports its real state/position back through the `player_report_*` commands,
//! so the Rust `PlayerController` remains the single source of truth (the
//! controller's `report_html5_*` methods fold those reports into the normal
//! event pipeline).
//!
//! Because the reported state flows through the event pipeline (not through this
//! backend's `position()`/`state()` pollers — the timer loop does not poll the
//! backend for HTML5-rendered media), this backend only needs to keep a
//! backend for webview-rendered media), this backend only needs to keep a
//! best-effort local mirror for direct `player_get_state` queries.
//!
//! TRACES: UR-003, UR-004, UR-005 | DR-004
+2 -2
View File
@@ -22,8 +22,8 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost http: https:; worker-src 'self' blob:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost http: https: ws: wss:; worker-src 'self' blob:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'",
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost; worker-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost ws: wss:; worker-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'",
"assetProtocol": {
"enable": true,
"scope": [