fix(player): decide transport in Rust for webview media (DR-097)
Video on Android/Linux renders in a webview <video> element, and the frontend facade short-circuited play/pause/toggle straight into the adapter whenever one was registered. Html5PlayerAdapter.toggle() then decided play-vs-pause by reading el.paused off the DOM, so the Rust controller never saw the intent and could not serialise competing ones. el.paused flips transiently while an element buffers or settles a seek. Two intents ~150ms apart therefore read *different* values and performed *opposing* actions — one playing, one pausing — which self-sustained a play/pause loop that needed no further input. On device this showed up as a fully healthy element (readyState=4, networkState=1, not seeking, not buffering, not ended) pausing itself roughly once a second, so unpausing or skipping ahead bounced straight back to paused. The root cause was that Rust held NO state for webview-rendered media: report_html5_state only re-emitted its argument, despite the comment above it claiming the controller was the single source of truth. It had nothing to decide a toggle from. Now report_html5_state tracks the reported state, and play/pause/toggle consult it and drive the element by emitting a ControlCommand — the same "backend decides, adapter executes the primitive" split player_seek_video already uses. A stopped/idle report clears the tracking so MPV/ExoPlayer regain authority for music playback. Tests cover the loop signature directly (repeated toggles must alternate, never repeat or oppose) plus a guard that one intent yields exactly one ControlCommand — which matters on Windows, where the backend is itself webview-based and could otherwise be driven twice.
This commit is contained in:
@@ -248,6 +248,7 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
|
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
|
||||||
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` returns `pending` for a first tap — the component defers `togglePlayPause` behind a `DOUBLE_TAP_WINDOW_MS` (300 ms) timer that a second tap cancels — or `seek` (+30 s right / −10 s left) for a second tap inside the window; a consumed second tap resets the state so a third tap starts fresh, and a swipe cancels the pending tap. The compatibility `click` the browser synthesizes after a touch tap is filtered in `handleVideoClick` so it cannot bypass the deferral. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped per DR-095 and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
|
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` returns `pending` for a first tap — the component defers `togglePlayPause` behind a `DOUBLE_TAP_WINDOW_MS` (300 ms) timer that a second tap cancels — or `seek` (+30 s right / −10 s left) for a second tap inside the window; a consumed second tap resets the state so a third tap starts fresh, and a swipe cancels the pending tap. The compatibility `click` the browser synthesizes after a touch tap is filtered in `handleVideoClick` so it cannot bypass the deferral. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped per DR-095 and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
|
||||||
| DR-094 | Frontend boundary tripwire (`scripts/check-frontend-boundary.sh`) detects Jellyfin item-type array literals **anywhere** in `src/` rather than only inline at an `includeItemTypes:` query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the `scoped-search` leak pass CI); requires two adjacent type literals so single-type presentation and `item.type ===` inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptions | Tooling | - | Done |
|
| DR-094 | Frontend boundary tripwire (`scripts/check-frontend-boundary.sh`) detects Jellyfin item-type array literals **anywhere** in `src/` rather than only inline at an `includeItemTypes:` query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the `scoped-search` leak pass CI); requires two adjacent type literals so single-type presentation and `item.type ===` inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptions | Tooling | - | Done |
|
||||||
|
| DR-097 | Transport authority (play/pause/toggle) lives in Rust for **webview-rendered** media, not just native. The controller tracks the state the HTML5 element reports (`html5_playing`, fed by `report_html5_state`, which now *stores* rather than only re-emitting); `play`/`pause`/`toggle_playback` consult it and drive the element by emitting a `ControlCommand` that `playerEvents.handleControlCommand` executes against the active adapter. A `stopped`/`idle` report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: `adapter.toggle()` previously decided play-vs-pause by reading `el.paused` off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read *different* values, performed *opposing* actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered `readyState=4 networkState=1` element). Same "backend decides, adapter executes the primitive" split as `player_seek_video` | Player | UR-005 | Done |
|
||||||
| DR-096 | `Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused | Player | UR-005 | Done |
|
| DR-096 | `Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused | Player | UR-005 | Done |
|
||||||
| DR-095 | Seek targets clamp strictly *inside* the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start | UI | UR-061 | Done |
|
| DR-095 | Seek targets clamp strictly *inside* the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start | UI | UR-061 | Done |
|
||||||
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
||||||
|
|||||||
@@ -175,8 +175,8 @@ describe("live requirements.md", () => {
|
|||||||
|
|
||||||
expect(defined.UR).toBe(61);
|
expect(defined.UR).toBe(61);
|
||||||
expect(defined.IR).toBe(29);
|
expect(defined.IR).toBe(29);
|
||||||
expect(defined.DR).toBe(93);
|
expect(defined.DR).toBe(94);
|
||||||
expect(defined.JA).toBe(32);
|
expect(defined.JA).toBe(32);
|
||||||
expect(defined.total).toBe(215);
|
expect(defined.total).toBe(216);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+231
-1
@@ -152,6 +152,17 @@ pub struct PlayerController {
|
|||||||
|
|
||||||
// Auto-play episode counter (session-based, resets on manual play)
|
// Auto-play episode counter (session-based, resets on manual play)
|
||||||
autoplay_episode_count: Arc<Mutex<u32>>,
|
autoplay_episode_count: Arc<Mutex<u32>>,
|
||||||
|
|
||||||
|
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// REPORTED state here is what lets transport (play/pause/toggle) be decided
|
||||||
|
// in Rust for that media instead of the frontend reading `el.paused` off the
|
||||||
|
// DOM — a value that flips transiently while buffering/seeking and caused
|
||||||
|
// competing intents to take opposing actions. `None` means no webview media
|
||||||
|
// is active and the native backend is authoritative. See DR-097.
|
||||||
|
html5_playing: Arc<Mutex<Option<bool>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerController {
|
impl PlayerController {
|
||||||
@@ -174,6 +185,7 @@ impl PlayerController {
|
|||||||
position_throttler,
|
position_throttler,
|
||||||
end_reason: Arc::new(Mutex::new(None)),
|
end_reason: Arc::new(Mutex::new(None)),
|
||||||
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
||||||
|
html5_playing: Arc::new(Mutex::new(None)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start background timer thread for sleep timer countdown
|
// Start background timer thread for sleep timer countdown
|
||||||
@@ -476,21 +488,72 @@ impl PlayerController {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real
|
||||||
|
/// player, so transport must be routed to it rather than the native backend.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-097
|
||||||
|
pub fn is_html5_active(&self) -> bool {
|
||||||
|
self.html5_playing.lock_safe().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the webview element last reported itself as playing. Meaningless
|
||||||
|
/// unless [`Self::is_html5_active`] is true.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-097
|
||||||
|
pub fn html5_is_playing(&self) -> bool {
|
||||||
|
self.html5_playing.lock_safe().unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a transport intent to the webview element that is rendering media.
|
||||||
|
fn emit_html5_control(&self, action: &str) {
|
||||||
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
||||||
|
emitter.emit(PlayerStatusEvent::ControlCommand {
|
||||||
|
action: action.to_string(),
|
||||||
|
position: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Play/resume playback
|
/// Play/resume playback
|
||||||
pub fn play(&self) -> Result<(), PlayerError> {
|
pub fn play(&self) -> Result<(), PlayerError> {
|
||||||
debug!("[PlayerController] play");
|
debug!("[PlayerController] play");
|
||||||
|
// Webview-rendered media: the native backend isn't playing it, so drive
|
||||||
|
// the element via a ControlCommand instead (DR-097).
|
||||||
|
if self.is_html5_active() {
|
||||||
|
self.emit_html5_control("play");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let mut backend = self.backend.lock_safe();
|
let mut backend = self.backend.lock_safe();
|
||||||
backend.play()
|
backend.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pause playback
|
/// Pause playback
|
||||||
pub fn pause(&self) -> Result<(), PlayerError> {
|
pub fn pause(&self) -> Result<(), PlayerError> {
|
||||||
|
if self.is_html5_active() {
|
||||||
|
self.emit_html5_control("pause");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let mut backend = self.backend.lock_safe();
|
let mut backend = self.backend.lock_safe();
|
||||||
backend.pause()
|
backend.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle play/pause
|
/// 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.
|
||||||
|
/// The frontend must never decide this from the DOM (see DR-097).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-097
|
||||||
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
|
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
|
||||||
|
if self.is_html5_active() {
|
||||||
|
let action = if self.html5_is_playing() {
|
||||||
|
"pause"
|
||||||
|
} else {
|
||||||
|
"play"
|
||||||
|
};
|
||||||
|
self.emit_html5_control(action);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let mut backend = self.backend.lock_safe();
|
let mut backend = self.backend.lock_safe();
|
||||||
if backend.state().is_playing() {
|
if backend.state().is_playing() {
|
||||||
backend.pause()
|
backend.pause()
|
||||||
@@ -890,6 +953,23 @@ impl PlayerController {
|
|||||||
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
|
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
|
||||||
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
|
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
|
||||||
pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
|
pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
|
||||||
|
// Track it: this is the authoritative play/pause state for
|
||||||
|
// webview-rendered media, and what transport decisions read (DR-097).
|
||||||
|
// "stopped"/"idle" mean the element is gone, so hand authority back to
|
||||||
|
// the native backend — otherwise music playback would keep emitting
|
||||||
|
// ControlCommands at a element that no longer exists.
|
||||||
|
{
|
||||||
|
let mut tracked = self.html5_playing.lock_safe();
|
||||||
|
*tracked = match state.as_str() {
|
||||||
|
"playing" => Some(true),
|
||||||
|
// "loading" counts as active-but-not-playing so a toggle during
|
||||||
|
// load resolves to "play" rather than falling through to the
|
||||||
|
// native backend.
|
||||||
|
"paused" | "loading" => Some(false),
|
||||||
|
// "stopped"/"idle": element is gone, native backend resumes authority.
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
|
emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
|
||||||
}
|
}
|
||||||
@@ -1489,6 +1569,156 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== HTML5 transport authority (DR-097) =====
|
||||||
|
//
|
||||||
|
// Webview-rendered video is played by an element the native backend cannot
|
||||||
|
// reach, so transport for it must be decided from the state the element
|
||||||
|
// REPORTS and executed by emitting a ControlCommand. Previously the frontend
|
||||||
|
// decided play-vs-pause itself by reading `el.paused` off the DOM, which
|
||||||
|
// flips transiently while buffering/seeking — two intents ~150ms apart read
|
||||||
|
// different values, took opposing actions, and self-sustained a pause loop.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html5_state_is_tracked_from_reports() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
|
||||||
|
// No HTML5 media reported yet: the native backend stays authoritative.
|
||||||
|
assert!(!controller.is_html5_active());
|
||||||
|
|
||||||
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
||||||
|
assert!(controller.is_html5_active());
|
||||||
|
assert!(controller.html5_is_playing());
|
||||||
|
|
||||||
|
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
||||||
|
assert!(controller.is_html5_active());
|
||||||
|
assert!(!controller.html5_is_playing());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html5_toggle_from_paused_emits_play_control() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
||||||
|
|
||||||
|
controller.toggle_playback().unwrap();
|
||||||
|
|
||||||
|
let controls: Vec<_> = emitter
|
||||||
|
.events()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(controls, vec!["play".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html5_toggle_from_playing_emits_pause_control() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
||||||
|
|
||||||
|
controller.toggle_playback().unwrap();
|
||||||
|
|
||||||
|
let controls: Vec<_> = emitter
|
||||||
|
.events()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(controls, vec!["pause".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
|
||||||
|
// The loop signature: two intents in quick succession must NOT both
|
||||||
|
// resolve the same way, and must not produce opposing actions from a
|
||||||
|
// stale read. Rust's own tracked state makes the sequence deterministic
|
||||||
|
// as long as the element reports back between intents.
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
||||||
|
|
||||||
|
controller.toggle_playback().unwrap();
|
||||||
|
// Element confirms the pause it was told to do.
|
||||||
|
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
||||||
|
controller.toggle_playback().unwrap();
|
||||||
|
|
||||||
|
let controls: Vec<_> = emitter
|
||||||
|
.events()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html5_play_and_pause_emit_control_commands() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
||||||
|
|
||||||
|
controller.play().unwrap();
|
||||||
|
controller.pause().unwrap();
|
||||||
|
|
||||||
|
let controls: Vec<_> = emitter
|
||||||
|
.events()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html5_stopped_report_releases_transport_to_native_backend() {
|
||||||
|
// When webview video goes away, transport must fall back to the native
|
||||||
|
// backend (music playback must not keep emitting ControlCommands).
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
|
||||||
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
||||||
|
assert!(controller.is_html5_active());
|
||||||
|
|
||||||
|
controller.report_html5_state("stopped".to_string(), None);
|
||||||
|
assert!(!controller.is_html5_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html5_transport_emits_exactly_one_control_per_intent() {
|
||||||
|
// Guards against a double-drive on platforms where the *backend* is also
|
||||||
|
// webview-based (WebviewAudioBackend on Windows): the html5 short-circuit
|
||||||
|
// must replace the backend call, not run in addition to it.
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
||||||
|
|
||||||
|
controller.pause().unwrap();
|
||||||
|
|
||||||
|
let controls = emitter
|
||||||
|
.events()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
|
||||||
|
.count();
|
||||||
|
assert_eq!(controls, 1, "one intent must produce exactly one control");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_controller_volume_default() {
|
fn test_controller_volume_default() {
|
||||||
let controller = PlayerController::default();
|
let controller = PlayerController::default();
|
||||||
|
|||||||
+15
-4
@@ -12,7 +12,7 @@
|
|||||||
* derived + merged (remote-session-aware) stores so UI can import state and
|
* derived + merged (remote-session-aware) stores so UI can import state and
|
||||||
* actions from one place, in both local and remote modes.
|
* actions from one place, in both local and remote modes.
|
||||||
*
|
*
|
||||||
* TRACES: UR-005 | DR-001, DR-009
|
* TRACES: UR-005 | DR-001, DR-009, DR-097 | UT-089
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
@@ -83,18 +83,29 @@ function requireHandle(): string {
|
|||||||
// Transport controls (no repository handle required)
|
// Transport controls (no repository handle required)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Transport intents ALWAYS go to the backend, in both native and HTML5 modes.
|
||||||
|
//
|
||||||
|
// These used to short-circuit into the active video adapter, which made the
|
||||||
|
// webview the decider: `adapter.toggle()` read `el.paused` off the DOM and
|
||||||
|
// flipped the element, so Rust never saw the intent. `el.paused` flips
|
||||||
|
// transiently while an element buffers or settles a seek, so two intents
|
||||||
|
// ~150ms apart could read different values and take opposing actions — a
|
||||||
|
// self-sustaining play/pause loop.
|
||||||
|
//
|
||||||
|
// Now Rust decides from PlayerController state and drives the element back
|
||||||
|
// through a `ControlCommand` event (handled in playerEvents.ts), the same
|
||||||
|
// "backend decides, adapter executes the primitive" split used by
|
||||||
|
// player_seek_video. Do NOT reintroduce an adapter short-circuit here.
|
||||||
|
|
||||||
async function play() {
|
async function play() {
|
||||||
if (activeAdapter) return void (await activeAdapter.play());
|
|
||||||
await commands.playerPlay();
|
await commands.playerPlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pause() {
|
async function pause() {
|
||||||
if (activeAdapter) return void (await activeAdapter.pause());
|
|
||||||
await commands.playerPause();
|
await commands.playerPause();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggle() {
|
async function toggle() {
|
||||||
if (activeAdapter) return void (await activeAdapter.toggle());
|
|
||||||
await commands.playerToggle();
|
await commands.playerToggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Transport authority: play/pause/toggle are DECIDED in Rust, never in the webview.
|
||||||
|
*
|
||||||
|
* TRACES: UR-005 | DR-097 | UT-089
|
||||||
|
*
|
||||||
|
* The frontend used to short-circuit transport controls whenever a video adapter
|
||||||
|
* was registered: `toggle()` read `el.paused` off the DOM and flipped the element
|
||||||
|
* directly, so the Rust `PlayerController` never saw the intent and could not
|
||||||
|
* serialise competing ones. Because `el.paused` flips transiently while an HTML5
|
||||||
|
* element buffers or settles a seek, two intents arriving ~150ms apart could read
|
||||||
|
* *different* values and perform *opposing* actions — one playing, one pausing —
|
||||||
|
* which is the self-sustaining play/pause loop observed on Android.
|
||||||
|
*
|
||||||
|
* The rule these tests pin: a transport intent always reaches the backend. Rust
|
||||||
|
* decides play-vs-pause from controller state and drives the webview element back
|
||||||
|
* through a ControlCommand event (the same "backend decides, adapter executes"
|
||||||
|
* split `player_seek_video` already uses).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const mockCommands = {
|
||||||
|
playerPlay: vi.fn(async () => ({})),
|
||||||
|
playerPause: vi.fn(async () => ({})),
|
||||||
|
playerToggle: vi.fn(async () => ({})),
|
||||||
|
playerStop: vi.fn(async () => ({})),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("$lib/api/bindings", () => ({
|
||||||
|
commands: mockCommands,
|
||||||
|
// Stores pulled in transitively subscribe to typed events at module load.
|
||||||
|
events: {
|
||||||
|
playerStatusEvent: { listen: vi.fn(async () => () => {}) },
|
||||||
|
downloadEvent: { listen: vi.fn(async () => () => {}) },
|
||||||
|
searchEvent: { listen: vi.fn(async () => () => {}) },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/auth", () => ({
|
||||||
|
auth: {
|
||||||
|
subscribe: (fn: (v: unknown) => void) => {
|
||||||
|
fn({ isAuthenticated: true });
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
getRepository: () => ({ getHandle: () => "handle-1" }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** A video adapter that records whether the facade reached into it directly. */
|
||||||
|
function makeAdapter() {
|
||||||
|
return {
|
||||||
|
kind: "html5" as const,
|
||||||
|
play: vi.fn(async () => {}),
|
||||||
|
pause: vi.fn(async () => {}),
|
||||||
|
toggle: vi.fn(async () => true),
|
||||||
|
seekElement: vi.fn(async () => {}),
|
||||||
|
reloadSource: vi.fn(async () => {}),
|
||||||
|
attach: vi.fn(),
|
||||||
|
dispose: vi.fn(async () => {}),
|
||||||
|
setVolume: vi.fn(),
|
||||||
|
setMuted: vi.fn(),
|
||||||
|
selectSubtitle: vi.fn(async () => {}),
|
||||||
|
getPosition: vi.fn(() => 0),
|
||||||
|
load: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("transport authority lives in Rust", () => {
|
||||||
|
let playerController: any;
|
||||||
|
let adapter: ReturnType<typeof makeAdapter>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
({ playerController } = await import("./index"));
|
||||||
|
adapter = makeAdapter();
|
||||||
|
playerController.setActiveAdapter(adapter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes toggle to the backend even when a video adapter is active", async () => {
|
||||||
|
await playerController.toggle();
|
||||||
|
|
||||||
|
expect(mockCommands.playerToggle).toHaveBeenCalledTimes(1);
|
||||||
|
// The webview must NOT decide play-vs-pause from the DOM.
|
||||||
|
expect(adapter.toggle).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes play to the backend even when a video adapter is active", async () => {
|
||||||
|
await playerController.play();
|
||||||
|
|
||||||
|
expect(mockCommands.playerPlay).toHaveBeenCalledTimes(1);
|
||||||
|
expect(adapter.play).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes pause to the backend even when a video adapter is active", async () => {
|
||||||
|
await playerController.pause();
|
||||||
|
|
||||||
|
expect(mockCommands.playerPause).toHaveBeenCalledTimes(1);
|
||||||
|
expect(adapter.pause).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still routes transport to the backend with no adapter (audio path unchanged)", async () => {
|
||||||
|
playerController.clearActiveAdapter();
|
||||||
|
|
||||||
|
await playerController.toggle();
|
||||||
|
await playerController.play();
|
||||||
|
await playerController.pause();
|
||||||
|
|
||||||
|
expect(mockCommands.playerToggle).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockCommands.playerPlay).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockCommands.playerPause).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* frontend stores accordingly. This enables push-based updates instead
|
* frontend stores accordingly. This enables push-based updates instead
|
||||||
* of polling.
|
* of polling.
|
||||||
*
|
*
|
||||||
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047, DR-097
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type UnlistenFn } from "@tauri-apps/api/event";
|
import { type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
@@ -306,9 +306,15 @@ function handleSleepTimerChanged(mode: SleepTimerMode, remainingSeconds: number)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Route a backend-originated control command to the active player adapter, so a
|
* Route a backend-originated control command to the active player adapter, so a
|
||||||
* backend intent (lockscreen/remote/sleep) can drive the webview <video> element
|
* backend intent can drive the webview <video>/<audio> element that Rust cannot
|
||||||
* that Rust cannot reach directly. No-op when no video adapter is active (audio
|
* reach directly. No-op when no adapter is active (native playback is already
|
||||||
* playback is already fully backend-driven).
|
* fully backend-driven).
|
||||||
|
*
|
||||||
|
* This is the EXECUTION half of transport authority: for webview-rendered media
|
||||||
|
* the Rust controller decides play-vs-pause from the state the element reported
|
||||||
|
* and emits it here as a ControlCommand. UI intents go *to* the backend (see the
|
||||||
|
* facade in $lib/player) and come back through this path — never short-circuited
|
||||||
|
* in the webview, which is what caused the DR-097 pause loop.
|
||||||
*/
|
*/
|
||||||
function handleControlCommand(action: string, position: number | null): void {
|
function handleControlCommand(action: string, position: number | null): void {
|
||||||
const adapter = playerController.getActiveAdapter();
|
const adapter = playerController.getActiveAdapter();
|
||||||
|
|||||||
Reference in New Issue
Block a user