Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e381d626c1 | ||
|
|
b12e99b7e1 | ||
|
|
dc8b732465 | ||
|
|
b98a530f48 | ||
|
|
b565c4ae6f | ||
|
|
79e10d7485 | ||
|
|
a2dbde5492 | ||
|
|
75cd07a5c0 | ||
|
|
64d07b8940 | ||
|
|
5b810f7fc3 | ||
|
|
1ae213ff39 | ||
|
|
98a6bca645 |
+11
-6
@@ -71,7 +71,7 @@ For a narrative overview of the system design, see
|
|||||||
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
||||||
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
|
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
|
||||||
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
|
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
|
||||||
| UR-061 | Double tapping the video skips within it — right half jumps **forward 30 seconds**, left half jumps **back 10 seconds** — with an on-screen indicator naming the amount. Because a double tap starts as a single tap, the single-tap play/pause is held back until the double-tap window has passed, so skipping never also pauses the video; the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot | Medium | Done |
|
| UR-061 | Double tapping the video skips within it — right half jumps **forward 30 seconds**, left half jumps **back 10 seconds** — with an on-screen indicator naming the amount. A double tap leaves the play state unchanged — playing jumps and keeps playing, paused jumps and stays paused — because the second tap re-toggles what the first tap toggled (see DR-098); the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot | Medium | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -246,8 +246,12 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
||||||
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | UR-060 | Done |
|
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | 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-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 to `[0, duration]` 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` classifies each tap and the component acts on it immediately — `togglePlayPause` for a first tap, or `seek` (+30 s right / −10 s left) plus a re-toggle for a second tap inside `DOUBLE_TAP_WINDOW_MS` (300 ms). A consumed pair resets the state, and a swipe forgets the tap. The deferral this originally used was removed in DR-098, which also covers suppressing the compatibility `click` the browser synthesizes after a touch tap. `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-098 | Video tap gestures act **immediately** — no deferral, no timer, and only first/second taps exist. A first tap toggles play/pause; a second tap inside `DOUBLE_TAP_WINDOW_MS` seeks *and* toggles again, so the two toggles cancel and a double tap preserves the play state (playing → jump and keep playing; paused → jump and stay paused). This replaces a design that deferred the first tap behind a 300 ms timer so a second tap could cancel it: the timer cleared its own handle *before* invoking the toggle, which reopened the `tapTimeout !== null` guard in `handleVideoClick` meant to suppress the compatibility `click` Android's WebView synthesizes after a touch — the late click then toggled a second time, producing a pause/unpause loop (long-press was unaffected, which is what identified the tap path). Click suppression no longer depends on the timer: `handleVideoClick` ignores `detail === 0` *and* any click within `TOUCH_CLICK_SUPPRESS_MS` of a touch tap. A swipe undoes the touchstart toggle exactly once (latched on `swipeGestureActive`) so brightness swipes never change play state. Click suppression is shared by **every** click target layered over the video via `isSynthesizedTouchClick`, not just the `<video>`: pausing renders a full-screen play-overlay button, so the synthesized click lands on *that* and an unguarded handler there resumed immediately — pausing appeared impossible while unpausing worked, because unpausing removes the overlay | UI | UR-061 | 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-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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -408,10 +412,11 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
||||||
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
|
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
|
||||||
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
|
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
|
||||||
| UT-085 | A first tap resolves to `pending`, not an immediate play/pause, and becomes `togglePlayPause` only once the double-tap window has elapsed | DR-092 | Done |
|
| UT-085 | A first tap resolves to `togglePlayPause` immediately — no deferral and no timer | DR-092, DR-098 | Done |
|
||||||
| UT-086 | A second tap inside the window seeks (+30 s right half, −10 s left half) with the matching feedback side, and clears the deferred play/pause so a double tap never pauses | DR-092 | Done |
|
| UT-086 | A second tap inside the window seeks (+30 s right half, −10 s left half) with the matching feedback side **and** re-toggles play/pause, so the two toggles cancel and the play state is unchanged by a double tap | DR-092, DR-098 | Done |
|
||||||
| UT-087 | A tap after the window, and a third tap after a consumed double tap, each start a fresh pending tap; repeated double taps keep seeking; `cancel()` drops a pending tap so a swipe cannot pause | DR-092 | Done |
|
| UT-087 | A tap after the window, and the tap following a consumed pair, are each fresh first taps that toggle (there is no third-tap case); repeated double taps keep seeking; `cancel()` makes the next tap a first tap so an interpreted swipe cannot seek | DR-092, DR-098 | Done |
|
||||||
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps to `[0, duration]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092 | Done |
|
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps into `[0, duration - END_SEEK_MARGIN_SECONDS]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092, DR-095 | Done |
|
||||||
|
| UT-091 | Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own `play`/`pause`/`toggle` — the webview must not decide play-vs-pause from the DOM | DR-097 | Done |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.2.1",
|
"version": "0.2.7",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
||||||
"android:build": "./scripts/build-android.sh",
|
"android:build": "./scripts/build-android.sh",
|
||||||
"android:build:release": "./scripts/build-android.sh release",
|
"android:build:release": "./scripts/build-android.sh release",
|
||||||
|
"android:build:device": "./scripts/build-android.sh --device",
|
||||||
|
"android:build:release:device": "./scripts/build-android.sh release --device",
|
||||||
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
|
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
|
||||||
"android:deploy": "./scripts/deploy-android.sh",
|
"android:deploy": "./scripts/deploy-android.sh",
|
||||||
"android:dev": "./scripts/build-and-deploy.sh",
|
"android:dev": "./scripts/build-and-deploy.sh",
|
||||||
|
|||||||
@@ -18,15 +18,50 @@ echo ""
|
|||||||
# Parse args: build type (debug/release) and optional --clean flag.
|
# Parse args: build type (debug/release) and optional --clean flag.
|
||||||
# By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
|
# By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
|
||||||
# Pass --clean (or CLEAN=1) to wipe all caches for a from-scratch build.
|
# Pass --clean (or CLEAN=1) to wipe all caches for a from-scratch build.
|
||||||
|
#
|
||||||
|
# ABI selection: by default Tauri builds all four ABIs (arm64/arm/x86/x86_64),
|
||||||
|
# which is what a distributable universal APK needs — but for an on-device test
|
||||||
|
# it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build
|
||||||
|
# only the connected device's architecture; --abi <t> targets one explicitly.
|
||||||
BUILD_TYPE="debug"
|
BUILD_TYPE="debug"
|
||||||
CLEAN="${CLEAN:-0}"
|
CLEAN="${CLEAN:-0}"
|
||||||
|
ABI="${ABI:-}"
|
||||||
|
next_is_abi=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
|
if [ "$next_is_abi" = "1" ]; then
|
||||||
|
ABI="$arg"
|
||||||
|
next_is_abi=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--clean) CLEAN=1 ;;
|
--clean) CLEAN=1 ;;
|
||||||
|
--abi) next_is_abi=1 ;;
|
||||||
|
--device) ABI="device" ;;
|
||||||
debug|release) BUILD_TYPE="$arg" ;;
|
debug|release) BUILD_TYPE="$arg" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Resolve --device to the attached device's Rust target triple.
|
||||||
|
if [ "$ABI" = "device" ]; then
|
||||||
|
device_abi="$(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r\n')"
|
||||||
|
case "$device_abi" in
|
||||||
|
arm64-v8a) ABI="aarch64" ;;
|
||||||
|
armeabi-v7a) ABI="armv7" ;;
|
||||||
|
x86_64) ABI="x86_64" ;;
|
||||||
|
x86) ABI="i686" ;;
|
||||||
|
*)
|
||||||
|
echo "⚠️ Could not detect device ABI (got '${device_abi:-none}') — building all targets."
|
||||||
|
ABI=""
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
[ -n "$ABI" ] && echo "🎯 Device ABI $device_abi → building only '$ABI'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGET_ARGS=()
|
||||||
|
if [ -n "$ABI" ]; then
|
||||||
|
TARGET_ARGS=(--target "$ABI")
|
||||||
|
fi
|
||||||
|
|
||||||
# Step 0: Optionally clear build caches for a fully fresh build.
|
# Step 0: Optionally clear build caches for a fully fresh build.
|
||||||
if [ "$CLEAN" = "1" ]; then
|
if [ "$CLEAN" = "1" ]; then
|
||||||
echo "🧹 Clearing build caches (clean build)..."
|
echo "🧹 Clearing build caches (clean build)..."
|
||||||
@@ -48,10 +83,10 @@ if [ "$BUILD_TYPE" = "release" ]; then
|
|||||||
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||||
./scripts/write-keystore-properties.sh
|
./scripts/write-keystore-properties.sh
|
||||||
echo "📦 Building release APK..."
|
echo "📦 Building release APK..."
|
||||||
bun run tauri android build --apk true
|
bun run tauri android build --apk true "${TARGET_ARGS[@]}"
|
||||||
else
|
else
|
||||||
echo "📦 Building debug APK..."
|
echo "📦 Building debug APK..."
|
||||||
bun run tauri android build --apk true --debug
|
bun run tauri android build --apk true --debug "${TARGET_ARGS[@]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -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(91);
|
expect(defined.DR).toBe(95);
|
||||||
expect(defined.JA).toBe(32);
|
expect(defined.JA).toBe(32);
|
||||||
expect(defined.total).toBe(213);
|
expect(defined.total).toBe(217);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.2.1"
|
version = "0.2.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.2.1"
|
version = "0.2.7"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
+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();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "jellytau",
|
"productName": "jellytau",
|
||||||
"version": "0.2.1",
|
"version": "0.2.7",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
createTapGestureState,
|
createTapGestureState,
|
||||||
registerTap,
|
registerTap,
|
||||||
resolveSeekTarget,
|
resolveSeekTarget,
|
||||||
|
clampSeekTarget,
|
||||||
|
isSynthesizedTouchClick,
|
||||||
|
isControlSurfaceTouch,
|
||||||
SEEK_FORWARD_SECONDS,
|
SEEK_FORWARD_SECONDS,
|
||||||
SEEK_BACKWARD_SECONDS,
|
SEEK_BACKWARD_SECONDS,
|
||||||
type TapFeedback,
|
type TapFeedback,
|
||||||
@@ -111,7 +114,9 @@
|
|||||||
let touchStartY = $state(0);
|
let touchStartY = $state(0);
|
||||||
let touchStartTime = $state(0);
|
let touchStartTime = $state(0);
|
||||||
let tapGestures = createTapGestureState();
|
let tapGestures = createTapGestureState();
|
||||||
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
|
// When a touch tap last ran the gesture handler, so the compatibility click
|
||||||
|
// the browser synthesizes afterwards can be ignored (see handleVideoClick).
|
||||||
|
let lastTouchTapAt = 0;
|
||||||
let brightness = $state(1); // 0-2, default 1
|
let brightness = $state(1); // 0-2, default 1
|
||||||
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
|
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
|
||||||
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -685,15 +690,19 @@
|
|||||||
bufferedRanges.push(`[${buffered.start(i).toFixed(1)} - ${buffered.end(i).toFixed(1)}]`);
|
bufferedRanges.push(`[${buffered.start(i).toFixed(1)} - ${buffered.end(i).toFixed(1)}]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[VideoPlayer Debug]", {
|
// Flattened to a single string on purpose: the Android WebView console
|
||||||
currentTime: videoElement.currentTime.toFixed(2),
|
// bridge stringifies objects as "[object Object]" in logcat, which made
|
||||||
displayTime: currentTime.toFixed(2),
|
// this whole payload useless when diagnosing over adb.
|
||||||
buffered: bufferedRanges.join(", "),
|
console.log(
|
||||||
readyState: videoElement.readyState,
|
`[VideoPlayer Debug] t=${videoElement.currentTime.toFixed(2)}` +
|
||||||
paused: videoElement.paused,
|
` display=${currentTime.toFixed(2)}` +
|
||||||
seeking: videoElement.seeking,
|
` readyState=${videoElement.readyState}` +
|
||||||
playbackRate: videoElement.playbackRate,
|
` networkState=${videoElement.networkState}` +
|
||||||
});
|
` paused=${videoElement.paused}` +
|
||||||
|
` seeking=${videoElement.seeking}` +
|
||||||
|
` rate=${videoElement.playbackRate}` +
|
||||||
|
` buffered=${bufferedRanges.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
});
|
});
|
||||||
@@ -714,11 +723,6 @@
|
|||||||
if (debugLogInterval) {
|
if (debugLogInterval) {
|
||||||
clearInterval(debugLogInterval);
|
clearInterval(debugLogInterval);
|
||||||
}
|
}
|
||||||
// A deferred single tap must not fire play/pause after teardown.
|
|
||||||
if (tapTimeout) {
|
|
||||||
clearTimeout(tapTimeout);
|
|
||||||
tapTimeout = null;
|
|
||||||
}
|
|
||||||
tapGestures.cancel();
|
tapGestures.cancel();
|
||||||
if (doubleTapFeedbackTimeout) {
|
if (doubleTapFeedbackTimeout) {
|
||||||
clearTimeout(doubleTapFeedbackTimeout);
|
clearTimeout(doubleTapFeedbackTimeout);
|
||||||
@@ -1100,6 +1104,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePause() {
|
function handlePause() {
|
||||||
|
// The element pausing is normally user intent, but a stall, a source change,
|
||||||
|
// or a competing controller can also do it — and the pause itself carries no
|
||||||
|
// reason. Log the element state so an unexplained pause/resume loop can be
|
||||||
|
// attributed from an adb capture instead of guessed at.
|
||||||
|
const el = videoElement;
|
||||||
|
console.log(
|
||||||
|
`[VideoPlayer] pause event — t=${el ? el.currentTime.toFixed(2) : "?"}` +
|
||||||
|
` readyState=${el?.readyState}` +
|
||||||
|
` networkState=${el?.networkState}` +
|
||||||
|
` seeking=${el?.seeking}` +
|
||||||
|
` ended=${el?.ended}` +
|
||||||
|
` isSeeking=${isSeeking}` +
|
||||||
|
` isBuffering=${isBuffering}` +
|
||||||
|
` handoff=${handoffState.active}`
|
||||||
|
);
|
||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
stopTimeUpdates(); // Stop RAF loop when paused
|
stopTimeUpdates(); // Stop RAF loop when paused
|
||||||
html5Adapter.reportState("paused", reportMediaId ?? null);
|
html5Adapter.reportState("paused", reportMediaId ?? null);
|
||||||
@@ -1147,7 +1166,10 @@
|
|||||||
|
|
||||||
async function handleSeekBarChange(e: Event) {
|
async function handleSeekBarChange(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const targetTime = parseFloat(input.value);
|
// Clamp strictly inside the media: the range input's max IS the duration, so
|
||||||
|
// dragging fully right would otherwise request a segment past the media end,
|
||||||
|
// which the server never produces (see END_SEEK_MARGIN_SECONDS).
|
||||||
|
const targetTime = clampSeekTarget(parseFloat(input.value), duration);
|
||||||
|
|
||||||
// Set isSeeking immediately to prevent timeupdate from interfering
|
// Set isSeeking immediately to prevent timeupdate from interfering
|
||||||
isSeeking = true;
|
isSeeking = true;
|
||||||
@@ -1421,8 +1443,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk up from the touch target collecting the tag/attribute pairs
|
||||||
|
* `isControlSurfaceTouch` needs, so the rule itself stays DOM-free and testable.
|
||||||
|
*/
|
||||||
|
function ancestorChain(target: EventTarget | null) {
|
||||||
|
const chain: Array<{
|
||||||
|
tag: string;
|
||||||
|
isPlayerControls?: boolean;
|
||||||
|
isPlayerSurface?: boolean;
|
||||||
|
}> = [];
|
||||||
|
let node = target as HTMLElement | null;
|
||||||
|
// Bounded walk: controls live a few levels below the player root, and
|
||||||
|
// stopping at <body> keeps this cheap and avoids depending on a bound ref.
|
||||||
|
while (node && node.tagName !== "BODY") {
|
||||||
|
chain.push({
|
||||||
|
tag: node.tagName ?? "",
|
||||||
|
isPlayerControls: node.dataset?.playerControls !== undefined,
|
||||||
|
isPlayerSurface: node.dataset?.playerSurface !== undefined,
|
||||||
|
});
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
// Touch gesture handlers
|
// Touch gesture handlers
|
||||||
function handleTouchStart(e: TouchEvent) {
|
function handleTouchStart(e: TouchEvent) {
|
||||||
|
// Taps on the controls belong to those controls. This listener is on the
|
||||||
|
// container and touch events bubble, so without this a tap on the bottom
|
||||||
|
// play button would toggle here AND again via the button's own click — the
|
||||||
|
// two cancelling out and leaving the control apparently dead (DR-098).
|
||||||
|
if (isControlSurfaceTouch(ancestorChain(e.target))) return;
|
||||||
|
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
touchStartX = touch.clientX;
|
touchStartX = touch.clientX;
|
||||||
touchStartY = touch.clientY;
|
touchStartY = touch.clientY;
|
||||||
@@ -1434,25 +1486,22 @@
|
|||||||
now: Date.now(),
|
now: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tapTimeout) {
|
// Suppress the compatibility click this touch will synthesize.
|
||||||
clearTimeout(tapTimeout);
|
lastTouchTapAt = Date.now();
|
||||||
tapTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outcome.action === "seek") {
|
if (outcome.action === "seek") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
|
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
|
||||||
|
// Re-toggle so the first tap's toggle is undone: a double tap seeks and
|
||||||
|
// leaves the play state as it was (playing keeps playing, paused stays
|
||||||
|
// paused).
|
||||||
|
if (outcome.togglePlayPause) togglePlayPause();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single tap so far: defer play/pause until the double-tap window closes,
|
// First tap: act now. Nothing is deferred, so there is no timer to race the
|
||||||
// so a double tap seeks without also toggling pause.
|
// compatibility click Android synthesizes after a touch tap (see DR-098).
|
||||||
tapTimeout = setTimeout(() => {
|
togglePlayPause();
|
||||||
tapTimeout = null;
|
|
||||||
if (tapGestures.resolvePending(Date.now())) {
|
|
||||||
togglePlayPause();
|
|
||||||
}
|
|
||||||
}, outcome.pendingAfterMs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchMove(e: TouchEvent) {
|
function handleTouchMove(e: TouchEvent) {
|
||||||
@@ -1465,14 +1514,16 @@
|
|||||||
|
|
||||||
// Minimum movement to register as swipe (50px)
|
// Minimum movement to register as swipe (50px)
|
||||||
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
|
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
|
||||||
swipeGestureActive = true;
|
// Only on the frame the gesture is first recognised as a swipe — this runs
|
||||||
|
// on every touchmove, and the correction below must happen exactly once.
|
||||||
// This is a swipe, not a tap — drop the deferred play/pause.
|
if (!swipeGestureActive) {
|
||||||
tapGestures.cancel();
|
// The touchstart already toggled play/pause (taps act immediately now),
|
||||||
if (tapTimeout) {
|
// so undo it: a swipe must not change the play state. Forget the tap too,
|
||||||
clearTimeout(tapTimeout);
|
// so it cannot pair with a later tap into a spurious seek.
|
||||||
tapTimeout = null;
|
togglePlayPause();
|
||||||
|
tapGestures.cancel();
|
||||||
}
|
}
|
||||||
|
swipeGestureActive = true;
|
||||||
|
|
||||||
// Brightness control on vertical swipe
|
// Brightness control on vertical swipe
|
||||||
swipeType = "brightness";
|
swipeType = "brightness";
|
||||||
@@ -1491,14 +1542,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mouse clicks toggle play/pause immediately. Touch taps are already handled
|
* Mouse clicks toggle play/pause immediately. Touch taps are handled fully by
|
||||||
* by `handleTouchStart` (which defers play/pause past the double-tap window),
|
* `handleTouchStart`, so the compatibility click the browser synthesizes after
|
||||||
* so the compatibility click that follows a tap must be ignored here —
|
* a tap must be ignored or every tap toggles twice.
|
||||||
* otherwise it pauses on the first tap of a double tap.
|
*
|
||||||
|
* Used by EVERY click target layered over the video, not just the <video>:
|
||||||
|
* pausing renders the full-screen play overlay, so the synthesized click lands
|
||||||
|
* on that button instead and would re-toggle straight back to playing.
|
||||||
*/
|
*/
|
||||||
function handleVideoClick(e: MouseEvent) {
|
function handleSurfaceClick(e: MouseEvent) {
|
||||||
// A click synthesized from a touch reports no pointer movement detail.
|
if (isSynthesizedTouchClick(e.detail, Date.now(), lastTouchTapAt)) return;
|
||||||
if (e.detail === 0 || tapTimeout !== null) return;
|
|
||||||
togglePlayPause();
|
togglePlayPause();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1666,7 +1719,7 @@
|
|||||||
onwaiting={handleWaiting}
|
onwaiting={handleWaiting}
|
||||||
onplaying={handlePlaying}
|
onplaying={handlePlaying}
|
||||||
onloadstart={handleLoadStart}
|
onloadstart={handleLoadStart}
|
||||||
onclick={handleVideoClick}
|
onclick={handleSurfaceClick}
|
||||||
>
|
>
|
||||||
<!-- Temporarily disabled to debug playback issues
|
<!-- Temporarily disabled to debug playback issues
|
||||||
{#each subtitleTracks() as track}
|
{#each subtitleTracks() as track}
|
||||||
@@ -1759,10 +1812,17 @@
|
|||||||
<div class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
<div class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
{:else if !isPlaying}
|
{:else if !isPlaying}
|
||||||
<!-- Play/Pause overlay -->
|
<!-- Play overlay. Visually this IS the video surface, so it is marked
|
||||||
|
`data-player-surface`: it must keep participating in tap gestures even
|
||||||
|
though it is a <button>, or the second tap of a double tap (which lands
|
||||||
|
here, because the first tap paused and raised this overlay) is
|
||||||
|
discarded as "a tap on a control" and seeking dies. It still shares the
|
||||||
|
synthesized-click guard, since it appears exactly when a tap pauses.
|
||||||
|
See DR-098. -->
|
||||||
<button
|
<button
|
||||||
|
data-player-surface
|
||||||
class="absolute inset-0 flex items-center justify-center bg-black/30"
|
class="absolute inset-0 flex items-center justify-center bg-black/30"
|
||||||
onclick={togglePlayPause}
|
onclick={handleSurfaceClick}
|
||||||
aria-label="Play"
|
aria-label="Play"
|
||||||
>
|
>
|
||||||
<svg class="w-20 h-20 text-white" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-20 h-20 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -1810,8 +1870,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls -->
|
<!-- Controls. `data-player-controls` marks this subtree as interactive so
|
||||||
|
container-level tap gestures ignore touches here (see DR-098). -->
|
||||||
<div
|
<div
|
||||||
|
data-player-controls
|
||||||
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300"
|
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300"
|
||||||
class:opacity-0={!showControls}
|
class:opacity-0={!showControls}
|
||||||
class:pointer-events-none={!showControls}
|
class:pointer-events-none={!showControls}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* Behavioural regression tests for the video tap surface — rendered against the
|
||||||
|
* REAL component, not a hand-modelled DOM.
|
||||||
|
*
|
||||||
|
* TRACES: UR-005, UR-061 | DR-098 | UT-092
|
||||||
|
*
|
||||||
|
* Why this file exists:
|
||||||
|
*
|
||||||
|
* `tapGestures.test.ts` tests `registerTap` / `isControlSurfaceTouch` /
|
||||||
|
* `isSynthesizedTouchClick` as isolated pure functions. Every one of those tests
|
||||||
|
* passed while, on the device, in sequence: the player pause-looped, then
|
||||||
|
* pausing became impossible, then the bottom controls went dead, then
|
||||||
|
* double-tap-to-seek stopped working. The helpers were each behaving exactly as
|
||||||
|
* specified — the bugs were all in the *composition*: which element actually
|
||||||
|
* receives a tap once Svelte has re-rendered.
|
||||||
|
*
|
||||||
|
* Testing my own helpers could not catch that, and modelling the DOM by hand in
|
||||||
|
* a test just re-encodes the same wrong assumption. So these tests render
|
||||||
|
* VideoPlayer and dispatch real touch/click events at whatever element is
|
||||||
|
* genuinely on top, asserting user-visible outcomes ("a double tap seeks")
|
||||||
|
* rather than internals.
|
||||||
|
*
|
||||||
|
* The specific traps encoded here, each a bug that shipped:
|
||||||
|
* - pausing renders a full-screen <button> play overlay OVER the video, so the
|
||||||
|
* second tap of a double tap lands on a button, not the video;
|
||||||
|
* - the browser synthesizes a `click` after a touch tap, which must not toggle
|
||||||
|
* a second time, on ANY layered target;
|
||||||
|
* - the bottom controls bar must drive its own buttons and NOT the container's
|
||||||
|
* tap gestures.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render } from "@testing-library/svelte";
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import VideoPlayer from "./VideoPlayer.svelte";
|
||||||
|
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
|
||||||
|
|
||||||
|
// --- Mocks: everything VideoPlayer reaches for that is not the tap surface. ---
|
||||||
|
|
||||||
|
const toggleSpy = vi.fn();
|
||||||
|
const seekVideoSpy = vi.fn();
|
||||||
|
const seekSpy = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("$app/navigation", () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("$lib/player", () => ({
|
||||||
|
playerController: {
|
||||||
|
toggle: (...a: unknown[]) => {
|
||||||
|
toggleSpy(...a);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
seekVideo: (...a: unknown[]) => {
|
||||||
|
seekVideoSpy(...a);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
seek: (...a: unknown[]) => {
|
||||||
|
seekSpy(...a);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
setActiveAdapter: vi.fn(),
|
||||||
|
clearActiveAdapter: vi.fn(),
|
||||||
|
getActiveAdapter: vi.fn(() => null),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/player/adapters/rustReportHost", () => ({
|
||||||
|
createRustReportHost: () => ({
|
||||||
|
onState: vi.fn(),
|
||||||
|
onPosition: vi.fn(),
|
||||||
|
onMediaLoaded: vi.fn(),
|
||||||
|
onEnded: vi.fn(),
|
||||||
|
onError: vi.fn(),
|
||||||
|
onStreamUrlChanged: vi.fn(),
|
||||||
|
onBuffering: vi.fn(),
|
||||||
|
onReady: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/player/html5Adapter", () => ({
|
||||||
|
reportState: vi.fn(),
|
||||||
|
reportPosition: vi.fn(),
|
||||||
|
reportMediaLoaded: vi.fn(),
|
||||||
|
resetReporting: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/utils/pictureInPicture", () => ({
|
||||||
|
isPipSupported: () => false,
|
||||||
|
enterPip: vi.fn(),
|
||||||
|
setAutoEnterEnabled: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/auth", () => ({
|
||||||
|
auth: {
|
||||||
|
getRepository: () => ({ getHandle: () => "h", jrayActorsAt: async () => [] }),
|
||||||
|
subscribe: (fn: (v: unknown) => void) => {
|
||||||
|
fn({ isAuthenticated: true });
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const MEDIA = {
|
||||||
|
id: "item-1",
|
||||||
|
name: "Test Episode",
|
||||||
|
type: "Episode",
|
||||||
|
runTimeTicks: 6_000_000_000, // 600s
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
/** Dispatch a touch at (x, y) on whatever element is topmost there. */
|
||||||
|
function touchAt(el: Element, x: number) {
|
||||||
|
const touch = { clientX: x, clientY: 300 } as Touch;
|
||||||
|
el.dispatchEvent(
|
||||||
|
new TouchEvent("touchstart", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
touches: [touch] as unknown as Touch[],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlayer() {
|
||||||
|
return render(VideoPlayer, {
|
||||||
|
props: { media: MEDIA, streamUrl: "http://x/master.m3u8", onClose: vi.fn() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VideoPlayer tap surface (real component)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a single tap on the video toggles play/pause exactly once", async () => {
|
||||||
|
const { container } = renderPlayer();
|
||||||
|
const video = container.querySelector("video");
|
||||||
|
expect(video).toBeTruthy();
|
||||||
|
|
||||||
|
touchAt(video!, 900);
|
||||||
|
|
||||||
|
expect(toggleSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the synthesized click after a tap does not toggle a second time", async () => {
|
||||||
|
const { container } = renderPlayer();
|
||||||
|
const video = container.querySelector("video")!;
|
||||||
|
|
||||||
|
touchAt(video, 900);
|
||||||
|
// The compatibility click the browser fires after a touch tap. detail=0 is
|
||||||
|
// how engines mark it; a late real-detail click is covered by the recency
|
||||||
|
// guard, which this exercises too since it lands immediately.
|
||||||
|
video.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 }));
|
||||||
|
|
||||||
|
expect(toggleSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a double tap seeks even though the first tap raised the play overlay", async () => {
|
||||||
|
// THE regression this file exists for. On device the first tap pauses, which
|
||||||
|
// makes Svelte render a full-screen <button> play overlay over the video —
|
||||||
|
// so the SECOND tap lands on a button, not the video. A control-surface
|
||||||
|
// guard that does not know about that overlay discards it and seeking dies.
|
||||||
|
//
|
||||||
|
// Reproducing it requires the overlay to actually render, which means
|
||||||
|
// driving `isPlaying` the way the real element does: via its `pause` event.
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { container } = renderPlayer();
|
||||||
|
const video = container.querySelector("video")!;
|
||||||
|
|
||||||
|
// Tap 1 on the video.
|
||||||
|
touchAt(video, 900);
|
||||||
|
|
||||||
|
// The element reports it paused → isPlaying=false → overlay renders.
|
||||||
|
video.dispatchEvent(new Event("pause"));
|
||||||
|
await Promise.resolve();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const overlay = container.querySelector("[data-player-surface]");
|
||||||
|
expect(overlay, "the play overlay should be covering the video").toBeTruthy();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(120); // inside DOUBLE_TAP_WINDOW_MS
|
||||||
|
// Tap 2 lands on the OVERLAY, exactly as on device.
|
||||||
|
touchAt(overlay!, 900);
|
||||||
|
|
||||||
|
// Either seek route is acceptable — which one runs depends on whether a
|
||||||
|
// video adapter is registered. What must hold is that a seek happened, to
|
||||||
|
// roughly the forward-skip target.
|
||||||
|
const calls = [...seekVideoSpy.mock.calls, ...seekSpy.mock.calls];
|
||||||
|
expect(calls.length).toBe(1);
|
||||||
|
const [position] = calls[0];
|
||||||
|
expect(position).toBeGreaterThan(0);
|
||||||
|
expect(position).toBeLessThanOrEqual(SEEK_FORWARD_SECONDS);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tapping the bottom play/pause button toggles once, not twice", async () => {
|
||||||
|
const { container } = renderPlayer();
|
||||||
|
const controls = container.querySelector("[data-player-controls]");
|
||||||
|
expect(controls).toBeTruthy();
|
||||||
|
|
||||||
|
const playBtn = controls!.querySelector("button");
|
||||||
|
expect(playBtn).toBeTruthy();
|
||||||
|
|
||||||
|
// A real press: touchstart bubbles to the container's gesture handler, then
|
||||||
|
// the button's own click fires. Only ONE toggle may result.
|
||||||
|
touchAt(playBtn!, 40);
|
||||||
|
playBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
|
||||||
|
|
||||||
|
expect(toggleSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,11 @@ import {
|
|||||||
createTapGestureState,
|
createTapGestureState,
|
||||||
registerTap,
|
registerTap,
|
||||||
resolveSeekTarget,
|
resolveSeekTarget,
|
||||||
|
clampSeekTarget,
|
||||||
|
END_SEEK_MARGIN_SECONDS,
|
||||||
|
isSynthesizedTouchClick,
|
||||||
|
isControlSurfaceTouch,
|
||||||
|
TOUCH_CLICK_SUPPRESS_MS,
|
||||||
} from "./tapGestures";
|
} from "./tapGestures";
|
||||||
|
|
||||||
const SCREEN_WIDTH = 1000;
|
const SCREEN_WIDTH = 1000;
|
||||||
@@ -25,24 +30,22 @@ function asSeek(outcome: ReturnType<typeof tap>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("tap gesture resolution", () => {
|
describe("tap gesture resolution", () => {
|
||||||
it("defers the single-tap action until the double-tap window has elapsed", () => {
|
// Every tap acts IMMEDIATELY — there is no deferral and no timer.
|
||||||
const state = createTapGestureState();
|
//
|
||||||
const first = tap(state, RIGHT, 1000);
|
// 1st tap: toggle play/pause
|
||||||
|
// 2nd tap: seek, then toggle play/pause AGAIN
|
||||||
|
//
|
||||||
|
// The second toggle undoes the first, so a double tap seeks while leaving the
|
||||||
|
// play state exactly as it was: playing -> jump and keep playing; paused ->
|
||||||
|
// jump and stay paused. The old design deferred the first tap behind a 300ms
|
||||||
|
// timer, which raced the synthesized click and produced a pause/unpause loop.
|
||||||
|
|
||||||
// The first tap must NOT immediately toggle play/pause — it may still
|
it("toggles play/pause immediately on the first tap", () => {
|
||||||
// become a double tap.
|
const state = createTapGestureState();
|
||||||
expect(first).toEqual({ action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS });
|
expect(tap(state, RIGHT, 1000)).toEqual({ action: "togglePlayPause" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves an isolated tap to togglePlayPause once the window expires", () => {
|
it("seeks forward 30s AND toggles again on a second right-side tap", () => {
|
||||||
const state = createTapGestureState();
|
|
||||||
tap(state, RIGHT, 1000);
|
|
||||||
|
|
||||||
const resolved = state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS);
|
|
||||||
expect(resolved).toEqual({ action: "togglePlayPause" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("seeks forward 30s on a double tap on the right half and never pauses", () => {
|
|
||||||
const state = createTapGestureState();
|
const state = createTapGestureState();
|
||||||
tap(state, RIGHT, 1000);
|
tap(state, RIGHT, 1000);
|
||||||
const second = asSeek(tap(state, RIGHT, 1150));
|
const second = asSeek(tap(state, RIGHT, 1150));
|
||||||
@@ -50,12 +53,11 @@ describe("tap gesture resolution", () => {
|
|||||||
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
|
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
|
||||||
expect(second.seekSeconds).toBe(30);
|
expect(second.seekSeconds).toBe(30);
|
||||||
expect(second.feedback).toBe("right");
|
expect(second.feedback).toBe("right");
|
||||||
|
// The re-toggle is what preserves the play state across a double tap.
|
||||||
// The deferred single-tap pause must have been cancelled.
|
expect(second.togglePlayPause).toBe(true);
|
||||||
expect(state.resolvePending(1150 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeks back 10s on a double tap on the left half", () => {
|
it("seeks back 10s AND toggles again on a second left-side tap", () => {
|
||||||
const state = createTapGestureState();
|
const state = createTapGestureState();
|
||||||
tap(state, LEFT, 1000);
|
tap(state, LEFT, 1000);
|
||||||
const second = asSeek(tap(state, LEFT, 1100));
|
const second = asSeek(tap(state, LEFT, 1100));
|
||||||
@@ -63,24 +65,44 @@ describe("tap gesture resolution", () => {
|
|||||||
expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
|
expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
|
||||||
expect(second.seekSeconds).toBe(-10);
|
expect(second.seekSeconds).toBe(-10);
|
||||||
expect(second.feedback).toBe("left");
|
expect(second.feedback).toBe("left");
|
||||||
|
expect(second.togglePlayPause).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a second tap after the window as a new pending single tap", () => {
|
it("net play state is unchanged by a double tap (two toggles cancel out)", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
let playing = true;
|
||||||
|
const apply = (outcome: ReturnType<typeof tap>) => {
|
||||||
|
if (outcome.action === "togglePlayPause") playing = !playing;
|
||||||
|
else if (outcome.action === "seek" && outcome.togglePlayPause) playing = !playing;
|
||||||
|
};
|
||||||
|
|
||||||
|
apply(tap(state, RIGHT, 1000)); // toggle -> paused
|
||||||
|
apply(tap(state, RIGHT, 1100)); // seek + toggle -> playing again
|
||||||
|
expect(playing).toBe(true);
|
||||||
|
|
||||||
|
// And from paused, a double tap leaves it paused.
|
||||||
|
playing = false;
|
||||||
|
apply(tap(state, RIGHT, 2000));
|
||||||
|
apply(tap(state, RIGHT, 2100));
|
||||||
|
expect(playing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a tap after the window as a fresh first tap", () => {
|
||||||
const state = createTapGestureState();
|
const state = createTapGestureState();
|
||||||
tap(state, RIGHT, 1000);
|
tap(state, RIGHT, 1000);
|
||||||
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
|
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
|
||||||
|
|
||||||
expect(late.action).toBe("pending");
|
expect(late.action).toBe("togglePlayPause");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not treat a third tap as another double tap", () => {
|
it("only ever has first and second taps — the tap after a pair is a fresh toggle", () => {
|
||||||
const state = createTapGestureState();
|
const state = createTapGestureState();
|
||||||
tap(state, RIGHT, 1000);
|
tap(state, RIGHT, 1000);
|
||||||
expect(tap(state, RIGHT, 1100).action).toBe("seek");
|
expect(tap(state, RIGHT, 1100).action).toBe("seek");
|
||||||
|
|
||||||
// Triple tap: the third tap starts a fresh pending tap rather than
|
// The pair is consumed. The next tap is a FIRST tap again, so it toggles
|
||||||
// seeking again off the consumed second tap.
|
// play/pause — there is no "third tap" concept.
|
||||||
expect(tap(state, RIGHT, 1200).action).toBe("pending");
|
expect(tap(state, RIGHT, 1200).action).toBe("togglePlayPause");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accumulates repeated double taps on the same side", () => {
|
it("accumulates repeated double taps on the same side", () => {
|
||||||
@@ -103,12 +125,13 @@ describe("tap gesture resolution", () => {
|
|||||||
expect(second.feedback).toBe("right");
|
expect(second.feedback).toBe("right");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cancel() drops a pending tap so an interpreted swipe cannot pause", () => {
|
it("cancel() makes the next tap a fresh first tap (swipe interrupted the pair)", () => {
|
||||||
const state = createTapGestureState();
|
const state = createTapGestureState();
|
||||||
tap(state, RIGHT, 1000);
|
tap(state, RIGHT, 1000);
|
||||||
state.cancel();
|
state.cancel();
|
||||||
|
|
||||||
expect(state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
// Without cancel() this would have been the seeking second tap.
|
||||||
|
expect(tap(state, RIGHT, 1100).action).toBe("togglePlayPause");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,8 +146,28 @@ describe("seek target resolution", () => {
|
|||||||
expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0);
|
expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clamps to the duration when skipping past the end", () => {
|
it("clamps short of the duration when skipping past the end", () => {
|
||||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(DURATION);
|
// Never land exactly on `duration`: hls.js would then request the segment
|
||||||
|
// that starts at/after the media end, which the server never produces —
|
||||||
|
// the fetch times out and the gap-controller stalls in a pause loop.
|
||||||
|
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(
|
||||||
|
DURATION - END_SEEK_MARGIN_SECONDS
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the end clamp strictly inside the media for a long transcoded item", () => {
|
||||||
|
// Regression: seeking near the end of a ~105min transcoded item clamped to
|
||||||
|
// the exact runtime (6330.324s), making hls.js fetch segment 1055 which
|
||||||
|
// starts at 6336.33s — past the end. That segment 404s/times out forever.
|
||||||
|
const runtime = 6330.324;
|
||||||
|
const target = resolveSeekTarget({ delta: 30, reportedPosition: 6320, duration: runtime });
|
||||||
|
|
||||||
|
expect(target).toBeLessThan(runtime);
|
||||||
|
expect(target).toBeCloseTo(runtime - END_SEEK_MARGIN_SECONDS, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clamp below zero for media shorter than the end margin", () => {
|
||||||
|
expect(resolveSeekTarget({ delta: 30, reportedPosition: 1, duration: 1 })).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("chains off a pending target so rapid taps do not compound off a stale position", () => {
|
it("chains off a pending target so rapid taps do not compound off a stale position", () => {
|
||||||
@@ -156,3 +199,78 @@ describe("seek target resolution", () => {
|
|||||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130);
|
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("control-surface touches are not gestures", () => {
|
||||||
|
// Regression: the gesture listener is on the outer container and touch events
|
||||||
|
// bubble, so tapping the bottom play/pause button ran the gesture handler
|
||||||
|
// (toggle #1) AND the button's own click handler (toggle #2). The two
|
||||||
|
// cancelled out and the control appeared dead.
|
||||||
|
it("treats a tap on a button as a control, not a gesture", () => {
|
||||||
|
expect(isControlSurfaceTouch([{ tag: "svg" }, { tag: "button" }, { tag: "div" }])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats the seek bar input as a control", () => {
|
||||||
|
expect(isControlSurfaceTouch([{ tag: "input" }, { tag: "div" }])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats anything inside the controls bar as a control", () => {
|
||||||
|
expect(
|
||||||
|
isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }])
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a tap on the bare video surface through as a gesture", () => {
|
||||||
|
expect(isControlSurfaceTouch([{ tag: "video" }, { tag: "div" }, { tag: "div" }])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive about tag names", () => {
|
||||||
|
expect(isControlSurfaceTouch([{ tag: "BUTTON" }])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("synthesized touch-click suppression", () => {
|
||||||
|
// Regression: pausing renders a full-screen play-overlay button over the
|
||||||
|
// video, so the compatibility click Android synthesizes from the tap lands on
|
||||||
|
// the OVERLAY, not the <video>. With no guard there it re-toggled and undid
|
||||||
|
// the pause — pausing looked impossible while unpausing worked fine (the
|
||||||
|
// overlay is removed when playing, so nothing intercepted that direction).
|
||||||
|
it("suppresses a click with detail 0 (clearly synthesized)", () => {
|
||||||
|
expect(isSynthesizedTouchClick(0, 10_000, 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses a real-detail click that closely follows a touch tap", () => {
|
||||||
|
const tapAt = 10_000;
|
||||||
|
expect(isSynthesizedTouchClick(1, tapAt + 120, tapAt)).toBe(true);
|
||||||
|
expect(isSynthesizedTouchClick(1, tapAt + TOUCH_CLICK_SUPPRESS_MS - 1, tapAt)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a genuine mouse click well after any touch", () => {
|
||||||
|
const tapAt = 10_000;
|
||||||
|
expect(isSynthesizedTouchClick(1, tapAt + TOUCH_CLICK_SUPPRESS_MS + 1, tapAt)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a genuine mouse click when no touch has ever happened", () => {
|
||||||
|
expect(isSynthesizedTouchClick(1, 10_000, 0)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("seek target clamping (shared by skip and seek-bar drag)", () => {
|
||||||
|
it("keeps a mid-stream target untouched", () => {
|
||||||
|
expect(clampSeekTarget(100, 600)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pulls a drag to the very end back inside the media", () => {
|
||||||
|
// The seek bar's max IS the duration, so dragging fully right yields
|
||||||
|
// exactly `duration` — the value that triggers the dead-segment stall.
|
||||||
|
expect(clampSeekTarget(6330.324, 6330.324)).toBeCloseTo(6330.324 - END_SEEK_MARGIN_SECONDS, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps negative and non-finite targets to zero", () => {
|
||||||
|
expect(clampSeekTarget(-5, 600)).toBe(0);
|
||||||
|
expect(clampSeekTarget(NaN, 600)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the target alone when the duration is unknown", () => {
|
||||||
|
expect(clampSeekTarget(500, 0)).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,18 +1,86 @@
|
|||||||
/**
|
/**
|
||||||
* Tap-gesture interpretation for the video player surface.
|
* Tap-gesture interpretation for the video player surface.
|
||||||
*
|
*
|
||||||
* Pulled out of `VideoPlayer.svelte` so the timing rules are unit-testable:
|
* Every tap acts IMMEDIATELY — there are only first and second taps, and no
|
||||||
* a tap cannot be classified at the moment it lands, because it may still turn
|
* deferral:
|
||||||
* out to be the first half of a double tap. Play/pause is therefore *deferred*
|
|
||||||
* until the double-tap window closes, and cancelled outright if a second tap
|
|
||||||
* arrives — otherwise a double tap both toggles pause and seeks.
|
|
||||||
*
|
*
|
||||||
* TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
|
* 1st tap: toggle play/pause
|
||||||
|
* 2nd tap (within the window): seek, then toggle play/pause AGAIN
|
||||||
|
*
|
||||||
|
* The second toggle undoes the first, so a double tap seeks while leaving the
|
||||||
|
* play state exactly as it started — playing stays playing, paused stays paused.
|
||||||
|
*
|
||||||
|
* This replaced a design that deferred the first tap behind a 300ms timer so it
|
||||||
|
* could be cancelled if a second tap arrived. That deferral raced the
|
||||||
|
* compatibility `click` Android's WebView synthesizes after a touch tap: the
|
||||||
|
* timer cleared its own handle *before* running the toggle, reopening the guard
|
||||||
|
* that was meant to suppress the late click, which then toggled a second time.
|
||||||
|
* The result was a play/pause loop about a second apart. Acting immediately
|
||||||
|
* removes the timer, the window race, and the loop.
|
||||||
|
*
|
||||||
|
* TRACES: UR-005, UR-061 | DR-092, DR-095, DR-098 | UT-085, UT-086, UT-087, UT-088
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** A second tap within this window makes a double tap. */
|
/** A second tap within this window pairs with the previous one (seek + re-toggle). */
|
||||||
export const DOUBLE_TAP_WINDOW_MS = 300;
|
export const DOUBLE_TAP_WINDOW_MS = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long after a touch tap a mouse `click` is assumed to be the compatibility
|
||||||
|
* event the browser synthesizes from that touch. Android's WebView can deliver it
|
||||||
|
* noticeably late, so this is generous.
|
||||||
|
*/
|
||||||
|
export const TOUCH_CLICK_SUPPRESS_MS = 700;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a touch landed on an interactive control rather than the bare video
|
||||||
|
* surface, and so must NOT be interpreted as a play/pause or seek gesture.
|
||||||
|
*
|
||||||
|
* The gesture listener sits on the outer container, and touch events bubble, so
|
||||||
|
* without this a tap on the bottom control bar runs the gesture handler (toggle
|
||||||
|
* #1) *and* the button's own click handler (toggle #2) — the two cancel out and
|
||||||
|
* the button appears dead. Buttons, links, inputs (the seek bar), and anything
|
||||||
|
* inside an element marked `data-player-controls` are treated as controls.
|
||||||
|
*
|
||||||
|
* Takes the ancestor chain as plain tag/attribute pairs so the rule is unit
|
||||||
|
* testable without a DOM.
|
||||||
|
*/
|
||||||
|
export function isControlSurfaceTouch(
|
||||||
|
ancestors: Array<{ tag: string; isPlayerControls?: boolean; isPlayerSurface?: boolean }>
|
||||||
|
): boolean {
|
||||||
|
const INTERACTIVE = new Set(["button", "a", "input", "select", "textarea", "label"]);
|
||||||
|
for (const node of ancestors) {
|
||||||
|
// `data-player-surface` wins over the tag check: the full-screen play overlay
|
||||||
|
// is a <button> but is visually the video itself, and must keep taking tap
|
||||||
|
// gestures — otherwise the second tap of a double tap (which lands on it,
|
||||||
|
// because the first tap paused and raised it) is discarded and seeking dies.
|
||||||
|
if (node.isPlayerSurface === true) return false;
|
||||||
|
if (node.isPlayerControls === true) return true;
|
||||||
|
if (INTERACTIVE.has(node.tag.toLowerCase())) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a `click` should be ignored because a touch tap already handled it.
|
||||||
|
*
|
||||||
|
* EVERY click target layered over the video must consult this — not just the
|
||||||
|
* `<video>` element. Pausing swaps in a full-screen play-overlay button, so the
|
||||||
|
* synthesized click lands on *that* button rather than the video, and an
|
||||||
|
* unguarded handler there re-toggles and undoes the pause (pause appeared
|
||||||
|
* impossible while unpause worked, because unpausing removes the overlay).
|
||||||
|
*
|
||||||
|
* `detail === 0` catches the synthesized click on engines that report it; the
|
||||||
|
* recency check covers engines that report a real `detail`.
|
||||||
|
*/
|
||||||
|
export function isSynthesizedTouchClick(
|
||||||
|
detail: number,
|
||||||
|
now: number,
|
||||||
|
lastTouchTapAt: number
|
||||||
|
): boolean {
|
||||||
|
if (detail === 0) return true;
|
||||||
|
return now - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS;
|
||||||
|
}
|
||||||
|
|
||||||
/** Double tap on the right half: skip forward. */
|
/** Double tap on the right half: skip forward. */
|
||||||
export const SEEK_FORWARD_SECONDS = 30;
|
export const SEEK_FORWARD_SECONDS = 30;
|
||||||
|
|
||||||
@@ -22,9 +90,18 @@ export const SEEK_BACKWARD_SECONDS = -10;
|
|||||||
export type TapFeedback = "left" | "right";
|
export type TapFeedback = "left" | "right";
|
||||||
|
|
||||||
export type TapOutcome =
|
export type TapOutcome =
|
||||||
/** Deferred: play/pause fires only if no second tap lands within the window. */
|
/** First tap: toggle play/pause right now. */
|
||||||
| { action: "pending"; pendingAfterMs: number }
|
| { action: "togglePlayPause" }
|
||||||
| { action: "seek"; seekSeconds: number; feedback: TapFeedback };
|
/**
|
||||||
|
* Second tap: seek, and toggle play/pause again so the first tap's toggle is
|
||||||
|
* undone and the play state survives the double tap unchanged.
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
action: "seek";
|
||||||
|
seekSeconds: number;
|
||||||
|
feedback: TapFeedback;
|
||||||
|
togglePlayPause: true;
|
||||||
|
};
|
||||||
|
|
||||||
export interface TapInput {
|
export interface TapInput {
|
||||||
/** Tap x position, viewport pixels. */
|
/** Tap x position, viewport pixels. */
|
||||||
@@ -35,32 +112,20 @@ export interface TapInput {
|
|||||||
|
|
||||||
export interface TapGestureState {
|
export interface TapGestureState {
|
||||||
/**
|
/**
|
||||||
* Resolve a still-pending single tap. Returns the play/pause action once the
|
* Forget the previous tap, so the next one is treated as a first tap. Used
|
||||||
* double-tap window has elapsed, or null if there is nothing pending (the tap
|
* when the gesture turns out to be a swipe.
|
||||||
* became a double tap, or was cancelled).
|
|
||||||
*/
|
*/
|
||||||
resolvePending(now: number): { action: "togglePlayPause" } | null;
|
|
||||||
/** Drop any pending tap — used when the gesture turns into a swipe. */
|
|
||||||
cancel(): void;
|
cancel(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InternalState extends TapGestureState {
|
interface InternalState extends TapGestureState {
|
||||||
lastTapTime: number;
|
lastTapTime: number;
|
||||||
pendingSince: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTapGestureState(): TapGestureState {
|
export function createTapGestureState(): TapGestureState {
|
||||||
const state: InternalState = {
|
const state: InternalState = {
|
||||||
lastTapTime: 0,
|
lastTapTime: 0,
|
||||||
pendingSince: null,
|
|
||||||
resolvePending(now: number) {
|
|
||||||
if (state.pendingSince === null) return null;
|
|
||||||
if (now - state.pendingSince < DOUBLE_TAP_WINDOW_MS) return null;
|
|
||||||
state.pendingSince = null;
|
|
||||||
return { action: "togglePlayPause" };
|
|
||||||
},
|
|
||||||
cancel() {
|
cancel() {
|
||||||
state.pendingSince = null;
|
|
||||||
state.lastTapTime = 0;
|
state.lastTapTime = 0;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -68,27 +133,64 @@ export function createTapGestureState(): TapGestureState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classify a tap. The first tap of a potential pair returns `pending` — the
|
* Classify a tap and return the action to perform *now*.
|
||||||
* caller schedules `resolvePending` after `pendingAfterMs`. A second tap inside
|
*
|
||||||
* the window returns the seek and clears the pending play/pause.
|
* A tap that closely follows another is the second of a pair: it seeks and
|
||||||
|
* re-toggles play/pause (undoing the first tap's toggle). Any other tap is a
|
||||||
|
* first tap and simply toggles. Nothing is deferred, so there is no window to
|
||||||
|
* race and no third-tap case — a consumed pair resets the state.
|
||||||
*/
|
*/
|
||||||
export function registerTap(state: TapGestureState, input: TapInput): TapOutcome {
|
export function registerTap(state: TapGestureState, input: TapInput): TapOutcome {
|
||||||
const s = state as InternalState;
|
const s = state as InternalState;
|
||||||
const sinceLastTap = input.now - s.lastTapTime;
|
const sinceLastTap = input.now - s.lastTapTime;
|
||||||
|
|
||||||
if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
|
if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
|
||||||
// Second tap: cancel the deferred play/pause and seek instead.
|
s.lastTapTime = 0; // pair consumed; the next tap is a first tap again
|
||||||
s.pendingSince = null;
|
|
||||||
s.lastTapTime = 0; // consumed, so a third tap starts fresh
|
|
||||||
const isLeftSide = input.x < input.screenWidth / 2;
|
const isLeftSide = input.x < input.screenWidth / 2;
|
||||||
return isLeftSide
|
return isLeftSide
|
||||||
? { action: "seek", seekSeconds: SEEK_BACKWARD_SECONDS, feedback: "left" }
|
? {
|
||||||
: { action: "seek", seekSeconds: SEEK_FORWARD_SECONDS, feedback: "right" };
|
action: "seek",
|
||||||
|
seekSeconds: SEEK_BACKWARD_SECONDS,
|
||||||
|
feedback: "left",
|
||||||
|
togglePlayPause: true,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
action: "seek",
|
||||||
|
seekSeconds: SEEK_FORWARD_SECONDS,
|
||||||
|
feedback: "right",
|
||||||
|
togglePlayPause: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
s.lastTapTime = input.now;
|
s.lastTapTime = input.now;
|
||||||
s.pendingSince = input.now;
|
return { action: "togglePlayPause" };
|
||||||
return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS };
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safety margin (seconds) kept between a clamped seek target and the media end.
|
||||||
|
*
|
||||||
|
* Landing *exactly* on `duration` makes hls.js request the segment whose start
|
||||||
|
* time is at/after the end of the media. The server never produces that segment,
|
||||||
|
* so the fetch times out and hls.js' gap-controller stalls forever at the last
|
||||||
|
* buffered position — surfacing as "unpausing bounces straight back to paused".
|
||||||
|
* One segment length (~6s for Jellyfin's ts segments) is comfortably clear of
|
||||||
|
* the final segment boundary.
|
||||||
|
*/
|
||||||
|
export const END_SEEK_MARGIN_SECONDS = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp an absolute seek target into the safely-playable range.
|
||||||
|
*
|
||||||
|
* Shared by the relative-skip path ({@link resolveSeekTarget}) and the seek-bar
|
||||||
|
* drag path, which can otherwise land exactly on `duration` because the range
|
||||||
|
* input's `max` is the duration itself.
|
||||||
|
*/
|
||||||
|
export function clampSeekTarget(target: number, duration: number): number {
|
||||||
|
if (!Number.isFinite(target) || target < 0) return 0;
|
||||||
|
if (duration > 0 && target > duration - END_SEEK_MARGIN_SECONDS) {
|
||||||
|
return Math.max(0, duration - END_SEEK_MARGIN_SECONDS);
|
||||||
|
}
|
||||||
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeekTargetInput {
|
export interface SeekTargetInput {
|
||||||
@@ -123,6 +225,10 @@ export function resolveSeekTarget(input: SeekTargetInput): number {
|
|||||||
|
|
||||||
const target = base + delta;
|
const target = base + delta;
|
||||||
if (target < 0) return 0;
|
if (target < 0) return 0;
|
||||||
if (duration > 0 && target > duration) return duration;
|
// Clamp strictly inside the media — see END_SEEK_MARGIN_SECONDS. Guard against
|
||||||
|
// going negative on media shorter than the margin itself.
|
||||||
|
if (duration > 0 && target > duration) {
|
||||||
|
return Math.max(0, duration - END_SEEK_MARGIN_SECONDS);
|
||||||
|
}
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,63 @@ describe("Html5PlayerAdapter", () => {
|
|||||||
expect(video.play).toHaveBeenCalledTimes(1);
|
expect(video.play).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A stalling HLS stream makes hls.js' gap-controller nudge the element, which
|
||||||
|
// aborts an in-flight play(). That AbortError is transient — the element is
|
||||||
|
// still trying to play — so it must not be surfaced as a player error, or the
|
||||||
|
// UI reports failure ~once a second for the whole stall.
|
||||||
|
it("play() does not report an interrupted-by-pause AbortError as an error", async () => {
|
||||||
|
const abort = new DOMException(
|
||||||
|
"The play() request was interrupted by a call to pause().",
|
||||||
|
"AbortError"
|
||||||
|
);
|
||||||
|
video.play = vi.fn(async () => {
|
||||||
|
throw abort;
|
||||||
|
});
|
||||||
|
|
||||||
|
await adapter.play();
|
||||||
|
|
||||||
|
expect(host.onError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("play() still reports a genuine failure", async () => {
|
||||||
|
video.play = vi.fn(async () => {
|
||||||
|
throw new DOMException("no supported source", "NotSupportedError");
|
||||||
|
});
|
||||||
|
|
||||||
|
await adapter.play();
|
||||||
|
|
||||||
|
expect(host.onError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String((host.onError as any).mock.calls[0][0])).toContain("play() failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("play() coalesces concurrent attempts into one element.play() call", async () => {
|
||||||
|
// During a stall the UI and recovery paths can both ask to play. Stacking
|
||||||
|
// element.play() calls is what generates the AbortError storm.
|
||||||
|
let resolvePlay: () => void = () => {};
|
||||||
|
video.play = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((r) => {
|
||||||
|
resolvePlay = () => {
|
||||||
|
video.paused = false;
|
||||||
|
r();
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = adapter.play();
|
||||||
|
const second = adapter.play();
|
||||||
|
resolvePlay();
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
|
||||||
|
expect(video.play).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("play() works again after a previous attempt settled", async () => {
|
||||||
|
await adapter.play();
|
||||||
|
await adapter.play();
|
||||||
|
expect(video.play).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("pause() calls element.pause()", async () => {
|
it("pause() calls element.pause()", async () => {
|
||||||
video.paused = false;
|
video.paused = false;
|
||||||
await adapter.pause();
|
await adapter.pause();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* intents flowing through the PlayerAdapter interface while preserving the
|
* intents flowing through the PlayerAdapter interface while preserving the
|
||||||
* hard-won element behavior verbatim.
|
* hard-won element behavior verbatim.
|
||||||
*
|
*
|
||||||
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028
|
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028, DR-096
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
||||||
@@ -41,10 +41,24 @@ export interface Html5ElementBridge {
|
|||||||
getMediaSourceId(): string | null;
|
getMediaSourceId(): string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for the `AbortError` the browser raises when a pending `play()` promise is
|
||||||
|
* cancelled by a `pause()` (or a source/seek change). It signals "that specific
|
||||||
|
* play attempt was superseded", not "playback failed" — hls.js' stall recovery
|
||||||
|
* produces it routinely, so it must not reach the player's error channel.
|
||||||
|
*/
|
||||||
|
function isPlayInterruptedError(err: unknown): boolean {
|
||||||
|
if (!err || typeof err !== "object") return false;
|
||||||
|
const { name, message } = err as { name?: string; message?: string };
|
||||||
|
return name === "AbortError" || (message ?? "").includes("interrupted");
|
||||||
|
}
|
||||||
|
|
||||||
export class Html5PlayerAdapter implements PlayerAdapter {
|
export class Html5PlayerAdapter implements PlayerAdapter {
|
||||||
readonly kind = "html5" as const;
|
readonly kind = "html5" as const;
|
||||||
|
|
||||||
private attachedElement: HTMLVideoElement | null = null;
|
private attachedElement: HTMLVideoElement | null = null;
|
||||||
|
/** In-flight play() attempt, so concurrent callers share one element.play(). */
|
||||||
|
private pendingPlay: Promise<void> | null = null;
|
||||||
private host: AdapterHost;
|
private host: AdapterHost;
|
||||||
private bridge: Html5ElementBridge;
|
private bridge: Html5ElementBridge;
|
||||||
|
|
||||||
@@ -81,12 +95,31 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
|||||||
async play(): Promise<void> {
|
async play(): Promise<void> {
|
||||||
const el = this.element;
|
const el = this.element;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
try {
|
// Coalesce concurrent attempts. While an HLS stream stalls, the UI and the
|
||||||
await el.play();
|
// gap-controller recovery path can both ask to play; stacking element.play()
|
||||||
// handlePlay on the element reports "playing"; no double-report here.
|
// calls is what turns one stall into an AbortError storm.
|
||||||
} catch (err) {
|
if (this.pendingPlay) return this.pendingPlay;
|
||||||
this.host.onError(`play() failed: ${err}`);
|
|
||||||
}
|
this.pendingPlay = (async () => {
|
||||||
|
try {
|
||||||
|
await el.play();
|
||||||
|
// handlePlay on the element reports "playing"; no double-report here.
|
||||||
|
} catch (err) {
|
||||||
|
// A play() aborted by a pause() is transient, not a failure: hls.js
|
||||||
|
// nudges the element to recover from a stall, which cancels the pending
|
||||||
|
// play promise while the element keeps trying. Surfacing it would report
|
||||||
|
// an error roughly once a second for the duration of the stall.
|
||||||
|
if (isPlayInterruptedError(err)) {
|
||||||
|
console.debug("[Html5PlayerAdapter] play() interrupted by pause (stall recovery)");
|
||||||
|
} else {
|
||||||
|
this.host.onError(`play() failed: ${err}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.pendingPlay = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return this.pendingPlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
async pause(): Promise<void> {
|
async pause(): Promise<void> {
|
||||||
|
|||||||
+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-091
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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-091
|
||||||
|
*
|
||||||
|
* 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