fix(player): make the video seek bar work by touch (DR-099)
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 6m30s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m25s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Failing after 6m4s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Windows (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 6m30s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m25s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Failing after 6m4s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Windows (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
On Android, dragging or tapping the progress bar moved the thumb but playback stayed where it was. Two separate defects, both touch-only, which is why the mouse-driven scrub tests never caught either. 1. Gesture hijack. DR-098 taught handleTouchStart to ignore touches that land on a control, but handleTouchMove kept running. It measures against touchStartX/Y, which that early return leaves at the PREVIOUS gesture's values, so a seek-bar drag produced a huge bogus vertical delta: read as a brightness swipe, it dimmed the screen to the 0.3 floor and fired a spurious play/pause "correction" mid-drag. A gesture is now latched at touchstart (playerGestureActive) and touchmove ignores anything unlatched — re-checking the move target cannot recover a start point that was never recorded. 2. Commit signal. The seek was committed only from `change`, which Android's WebView does not reliably fire for a touch interaction on a range input, so the thumb moved to the tapped position and no seek ever ran. touchend/mouseup now commit too; `input` arms a one-shot latch so whichever release signal arrives first commits and the other is a no-op. seekRelative shares the same commitSeek entry point instead of fabricating a synthetic change event. Tests drive the slider with real touch events (UT-089, UT-090) and fail against the pre-fix component.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092 -->
|
||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -124,6 +124,14 @@
|
||||
// so back-to-back double taps chain instead of stacking on a stale position.
|
||||
let pendingSeekTarget: number | null = null;
|
||||
let swipeGestureActive = $state(false);
|
||||
// Whether the in-flight touch belongs to the player surface (and so may be
|
||||
// read as a tap/swipe gesture) rather than to a control. Set on touchstart,
|
||||
// cleared on touchend — see handleTouchMove for why a per-gesture flag and not
|
||||
// just a per-event target check.
|
||||
let playerGestureActive = false;
|
||||
// Raised when the user changes the seek bar's value, cleared by whichever
|
||||
// release signal commits the seek. See handleSeekBarRelease.
|
||||
let seekCommitArmed = false;
|
||||
|
||||
// Backend info from Rust (Rust decides which backend to use based on platform)
|
||||
let useHtml5Element = $state(true); // Default to HTML5, Rust will override if using native backend
|
||||
@@ -1162,14 +1170,33 @@
|
||||
const targetTime = parseFloat(input.value);
|
||||
// Update the displayed time immediately for smooth visual feedback
|
||||
currentTime = targetTime;
|
||||
// The user has moved the value; the next release must commit it.
|
||||
seekCommitArmed = true;
|
||||
}
|
||||
|
||||
async function handleSeekBarChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
/**
|
||||
* Seek-bar released — commit the value the user landed on, at most once.
|
||||
*
|
||||
* Wired to `touchend`/`mouseup` AND `change`, because `change` alone is not
|
||||
* dependable: Android's WebView does not reliably fire it for a touch
|
||||
* interaction on a range input, so the thumb moved to the tapped position but
|
||||
* the seek never ran ("the bar moves, playback doesn't"). Engines that DO fire
|
||||
* `change` deliver both signals, hence the arm/disarm — whichever arrives
|
||||
* first commits and the other is a no-op.
|
||||
*/
|
||||
function handleSeekBarRelease(e: Event) {
|
||||
isDraggingSeekBar = false;
|
||||
if (!seekCommitArmed) return;
|
||||
seekCommitArmed = false;
|
||||
const input = (e.currentTarget ?? e.target) as HTMLInputElement;
|
||||
void commitSeek(parseFloat(input.value));
|
||||
}
|
||||
|
||||
async function commitSeek(rawTarget: number) {
|
||||
// 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);
|
||||
const targetTime = clampSeekTarget(rawTarget, duration);
|
||||
|
||||
// Set isSeeking immediately to prevent timeupdate from interfering
|
||||
isSeeking = true;
|
||||
@@ -1406,16 +1433,9 @@
|
||||
to: newTime.toFixed(2),
|
||||
});
|
||||
|
||||
// Call the unified handleSeekBarChange logic with the new time
|
||||
// Create a synthetic event to reuse the existing logic
|
||||
const syntheticEvent = {
|
||||
target: {
|
||||
value: newTime.toString()
|
||||
}
|
||||
} as unknown as Event;
|
||||
|
||||
// Same commit path as the seek bar — one place decides how a seek is issued.
|
||||
try {
|
||||
await handleSeekBarChange(syntheticEvent);
|
||||
await commitSeek(newTime);
|
||||
} finally {
|
||||
// The player is authoritative again from here on.
|
||||
if (pendingSeekTarget === newTime) pendingSeekTarget = null;
|
||||
@@ -1473,7 +1493,16 @@
|
||||
// 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;
|
||||
if (isControlSurfaceTouch(ancestorChain(e.target))) {
|
||||
// The move handler must stay out of it too. It reads touchStartX/Y, which
|
||||
// this early return leaves at the PREVIOUS gesture's values, so a seek-bar
|
||||
// drag came out as a huge vertical delta: it was mis-read as a brightness
|
||||
// swipe, which dimmed the screen and fired a spurious play/pause
|
||||
// "correction" mid-drag (DR-098).
|
||||
playerGestureActive = false;
|
||||
return;
|
||||
}
|
||||
playerGestureActive = true;
|
||||
|
||||
const touch = e.touches[0];
|
||||
touchStartX = touch.clientX;
|
||||
@@ -1505,6 +1534,10 @@
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
// Only a gesture that began on the bare video surface is ours. Re-checking
|
||||
// the target here would not be enough: the touch that started on a control
|
||||
// never recorded a start point, so any delta computed here is meaningless.
|
||||
if (!playerGestureActive) return;
|
||||
if (!e.touches[0]) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
@@ -1537,6 +1570,7 @@
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: TouchEvent) {
|
||||
playerGestureActive = false;
|
||||
swipeGestureActive = false;
|
||||
swipeType = null;
|
||||
}
|
||||
@@ -1900,11 +1934,11 @@
|
||||
max={duration || 100}
|
||||
value={currentTime}
|
||||
oninput={handleSeekBarInput}
|
||||
onchange={handleSeekBarChange}
|
||||
onchange={handleSeekBarRelease}
|
||||
onmousedown={() => isDraggingSeekBar = true}
|
||||
onmouseup={() => isDraggingSeekBar = false}
|
||||
onmouseup={handleSeekBarRelease}
|
||||
ontouchstart={() => isDraggingSeekBar = true}
|
||||
ontouchend={() => isDraggingSeekBar = false}
|
||||
ontouchend={handleSeekBarRelease}
|
||||
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
|
||||
|
||||
Reference in New Issue
Block a user