feat(web-ui): add touch swipe gestures to the web player - #705
Merged
Conversation
The player only had a keyboard/mouse-hover interaction model — touch handlers explicitly bailed out on pointerType === "touch", leaving a single tap (toggle controls) as the only touch affordance. On phones and tablets that meant zapping required opening the sidebar and volume required a hover-designed vertical slider. Add a touch-only gesture layer modeled after native IPTV apps: - vertical drag on the left half switches channels (up = previous, matching the ArrowUp shortcut), committed on release so a half-swipe can be aborted - vertical drag on the right half sets volume, tracking the finger live - horizontal drag seeks relative to the current position, same as the arrow keys - double tap toggles playback An on-screen indicator shows the target channel, volume level or seek delta while the gesture is in flight. The gesture layer is a dedicated transparent element above the video but below every overlay, so visible controls keep priority. touch-action is scoped to that element on purpose: putting it on the player surface would inherit into the settings popover and break its scrolling.
Contributor
Documentation previewThe documentation preview has been deployed for this pull request. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e80d8ca893
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
A channel with no catchup source has nothing to seek into. A target outside the MSE buffer takes the seek-needed path, and because no source can serve the window, player.tsx falls back to setSeekAtLiveEdge(true) — the stream is rebuilt at the live edge. The user gets a dropped connection and no seek. The timeline in PlayerControls already gated on this, so make the gesture and keyboard paths agree: guard handleRelativeSeek (covering both ArrowLeft/ArrowRight and the swipe) and lock a horizontal drag into an inert mode so no seek indicator is shown. Media Session seek buttons already required a catchup source; they now share the single isCatchupSupported expression.
…rites iOS and iPadOS make HTMLMediaElement.volume read-only: assignment is silently ignored, reads always return 1, and no volumechange fires. The player wrote video.volume and mirrored the state back from that event, so on iOS the volume gesture moved its indicator from 0 to 100% while the audio never changed — worse than having no control at all. Probe the capability instead of sniffing the user agent, so desktop Safari is not caught by it. Where volume cannot be set: - the control bar renders the mute button without the slider (muted is still settable on iOS, so muting keeps working) - the right-half volume gesture is off, and channel switching claims the full width instead of leaving that half inert The audio pipeline is untouched. Note that MP2 audio goes through PCMAudioPlayer and a Web Audio GainNode, which iOS does not restrict — that path could support volume on iOS if it stopped slaving the gain to video.volume, but that is a separate change.
Compare the touch against the layer's own midpoint. startX held a viewport coordinate but was compared against width / 2, which is local to the layer. The player wrapper carries pl-[env(safe-area-inset-left)], so in landscape on a notched device the boundary shifted left by the inset and a strip of the visible left half adjusted volume instead of changing channels. The half is now resolved at pointerdown, where the rect is at hand. Release a gesture stranded by an unmount. The layer unmounts on playback error or when autoplay needs a user gesture; a finger still down at that moment gets no pointerup or pointercancel, because React tears the handlers down first. The in-flight gesture stayed in the ref and, since the hook outlives channel switches, every later touch was either rejected or absorbed into the phantom gesture — permanently, until the player itself remounted. Clear the gesture when the hook is disabled, and treat lostpointercapture as cancellation the way the seek bar already does. Stop exposing the indicator to assistive tech. It is a visual echo of a gesture the user just performed: the volume readout changes on every pointermove, so role="status" would spam a polite live region, and screen readers consume swipes before they reach the layer. The state it reflects is already on the labelled controls in the control bar. It is now aria-hidden, and the content it retains for the fade-out is dropped once the transition ends instead of lingering in the DOM. Also switch the volume capability check from a probe to the existing iOS user-agent tag. Assigning to a detached element's volume reads the value back unchanged on iOS, so the probe reported support that playback did not honour. player.html already classifies iOS, covering the iOS-wrapped browsers and iPadOS reporting itself as MacIntel.
Tubetrue01
pushed a commit
to Tubetrue01/rtp2httpd
that referenced
this pull request
Aug 8, 2026
Closes stackia#702. ## Background The web player only had a keyboard and mouse-hover interaction model. `handlePointerHover` / `handlePointerLeave` explicitly bail out with `if (event.pointerType === "touch") return;`, so the only touch affordance was a single tap to toggle the controls. On phones and tablets that meant zapping required opening the sidebar and picking from the list, and adjusting volume required a vertical slider designed for mouse hover. ## What changed A touch-only gesture layer. Everything is gated on `pointerType === "touch"`, so mouse and pen behavior is untouched. | Gesture | Action | |---|---| | Vertical drag, left half | Switch channel. Up = previous, matching the `ArrowUp` shortcut. **Commits on release**, so a half-swipe can be aborted | | Vertical drag, right half | Volume, tracking the finger live (unmutes automatically when raised above zero) | | Horizontal drag | Relative seek, sharing the `handleRelativeSeek` path with the `←` / `→` keys | | Double tap | Play / pause | An on-screen indicator shows the target channel, volume level, or seek delta while the gesture is in flight. ### Files - `web-ui/src/hooks/use-player-touch-gestures.ts` (new) — the gesture state machine. Direction locks in after 12px; a vertical drag picks channel vs. volume by the starting x. Tuning constants live at the top of the file (channel threshold 15% of height clamped to 48–120px, volume full swing 60% of height, seek full swing 120s across the width, double tap 300ms / 40px). - `web-ui/src/components/player/player-gesture-overlay.tsx` (new) — the indicator. Reuses `PLAYER_OVERLAY_SURFACE_CLASS` and `PlayerSelectedGlassLayers`, and carries the `player-performance-*` classes so the "simple" appearance mode can strip blur and animation. - `web-ui/src/components/player/video-player.tsx` — adds `prevChannel` / `nextChannel` props, mounts the gesture layer, extends the `handleSurfaceClick` hit test, and adds click suppression. - `web-ui/src/pages/player.tsx` — computes the adjacent channels, wrapping exactly like `handleChannelNavigate`. - `web-ui/src/i18n/player.ts` — adds `volume` / `previousChannel` / `nextChannel` to all three dictionaries. ## Notes for reviewers **The gesture layer is a dedicated element, not the player surface.** It sits above the video and below every overlay (z-10 / z-20, including the control bar), so visible controls keep priority and their area does not start a gesture. `touch-action: none` is scoped to that element deliberately — putting it on the surface would inherit into the settings popover (`overflow-y-auto`) and break its scrolling. **Seeking is disabled entirely on channels without catchup** (2nd commit). A channel with no catchup source has nothing to seek into: a target outside the MSE buffer takes the `seek-needed` path, and since no source can serve the window, `player.tsx` falls back to `setSeekAtLiveEdge(true)` and rebuilds the stream at the live edge — a dropped connection with no seek to show for it. The timeline in `PlayerControls` already gated on this, so the gesture and keyboard paths now agree: `handleRelativeSeek` is guarded (covering both `←` / `→` and the swipe), and a horizontal drag locks into an inert mode so no seek indicator appears. All entry points share one `isCatchupSupported` expression. This does change existing keyboard behavior: `←` / `→` used to attempt a seek on non-catchup live channels, which would silently reconnect at the live edge. They are now no-ops there. **Volume controls are hidden where the platform ignores volume writes** (3rd commit). iOS and iPadOS make `HTMLMediaElement.volume` read-only: assignment is silently ignored, reads always return 1, and no `volumechange` fires. Since the player writes `video.volume` and mirrors state back from that event, the volume gesture on iOS moved its indicator from 0 to 100% while the audio never changed — worse than having no control at all. The capability is now probed (`isVolumeControlSupported()` in `lib/platform.ts`) rather than sniffed from the user agent, so desktop Safari is not caught. Where volume cannot be set: - the control bar renders the mute button without the slider (`muted` is still settable on iOS, so muting keeps working) - the right-half volume gesture is off, and **channel switching claims the full width** instead of leaving that half inert The audio pipeline is untouched. Worth noting for a possible follow-up: MP2 audio is software-decoded and played through `PCMAudioPlayer` → Web Audio `GainNode`, which iOS does *not* restrict. That path could support volume on iOS if it stopped slaving the gain to `video.volume` (`pcm-audio-player.ts` mirrors it on `volumechange`). Out of scope here. **`src/embedded_web_data.h` is not included**, per the `CLAUDE.md` convention that web UI rebuild output is not committed. ## Verification Local mock upstreams via `tools/devlab/devlab.py` plus rtp2httpd, driven with synthetic touch pointer events at a 375×812 mobile viewport. | Case | Result | |---|---| | Right half, swipe up | Volume 0 → 0.79, indicator reads 79%, control bar slider stays in sync | | Left half, swipe up | Indicator previews "14 mcast 2160p" (wrapping back from channel 1), switches on release | | Swipe past threshold, then back | Indicator fades, channel does not change | | Double tap | Pause ↔ Play, and forces the controls visible | | Catchup channel, horizontal swipe | Indicator reads "+1:04", position goes 0:08 → 1:13 | | Non-catchup channel, horizontal swipe | No indicator, no seek | | Non-catchup channel, `→ → ←` | No jump; playback advances at normal rate only | | Single tap | Still only toggles the controls | | Mouse click / drag | Unchanged | | Settings popover | `touch-action: auto`, scrolling unaffected | | Playback error showing | Gesture layer unmounts | Volume capability probe, checked against a `volume` accessor patched to behave like iOS: returns `true` normally, `false` when the setter is a no-op, `true` again once restored. The degraded UI was then exercised in a throwaway build with the probe forced to `false` (reverted before committing): slider absent, mute button present, and a vertical swipe on **either** half switches channels. One bug found and fixed during verification: a swipe usually produces no trailing click, so the click-suppression flag survived and swallowed the *next* legitimate tap. The flag is now cleared at the start of every `pointerdown`. `pnpm run lint:biome` and `pnpm run type-check:tsc` both pass. Not verified on a physical iOS device — the iOS behavior above is based on the documented WebKit restriction and the simulated probe. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #702.
Background
The web player only had a keyboard and mouse-hover interaction model.
handlePointerHover/handlePointerLeaveexplicitly bail out withif (event.pointerType === "touch") return;, so the only touch affordance was a single tap to toggle the controls. On phones and tablets that meant zapping required opening the sidebar and picking from the list, and adjusting volume required a vertical slider designed for mouse hover.What changed
A touch-only gesture layer. Everything is gated on
pointerType === "touch", so mouse and pen behavior is untouched.ArrowUpshortcut. Commits on release, so a half-swipe can be abortedhandleRelativeSeekpath with the←/→keysAn on-screen indicator shows the target channel, volume level, or seek delta while the gesture is in flight.
Files
web-ui/src/hooks/use-player-touch-gestures.ts(new) — the gesture state machine. Direction locks in after 12px; a vertical drag picks channel vs. volume by the starting x. Tuning constants live at the top of the file (channel threshold 15% of height clamped to 48–120px, volume full swing 60% of height, seek full swing 120s across the width, double tap 300ms / 40px).web-ui/src/components/player/player-gesture-overlay.tsx(new) — the indicator. ReusesPLAYER_OVERLAY_SURFACE_CLASSandPlayerSelectedGlassLayers, and carries theplayer-performance-*classes so the "simple" appearance mode can strip blur and animation.web-ui/src/components/player/video-player.tsx— addsprevChannel/nextChannelprops, mounts the gesture layer, extends thehandleSurfaceClickhit test, and adds click suppression.web-ui/src/pages/player.tsx— computes the adjacent channels, wrapping exactly likehandleChannelNavigate.web-ui/src/i18n/player.ts— addsvolume/previousChannel/nextChannelto all three dictionaries.Notes for reviewers
The gesture layer is a dedicated element, not the player surface. It sits above the video and below every overlay (z-10 / z-20, including the control bar), so visible controls keep priority and their area does not start a gesture.
touch-action: noneis scoped to that element deliberately — putting it on the surface would inherit into the settings popover (overflow-y-auto) and break its scrolling.Seeking is disabled entirely on channels without catchup (2nd commit). A channel with no catchup source has nothing to seek into: a target outside the MSE buffer takes the
seek-neededpath, and since no source can serve the window,player.tsxfalls back tosetSeekAtLiveEdge(true)and rebuilds the stream at the live edge — a dropped connection with no seek to show for it. The timeline inPlayerControlsalready gated on this, so the gesture and keyboard paths now agree:handleRelativeSeekis guarded (covering both←/→and the swipe), and a horizontal drag locks into an inert mode so no seek indicator appears. All entry points share oneisCatchupSupportedexpression.This does change existing keyboard behavior:
←/→used to attempt a seek on non-catchup live channels, which would silently reconnect at the live edge. They are now no-ops there.Volume controls are hidden where the platform ignores volume writes (3rd commit). iOS and iPadOS make
HTMLMediaElement.volumeread-only: assignment is silently ignored, reads always return 1, and novolumechangefires. Since the player writesvideo.volumeand mirrors state back from that event, the volume gesture on iOS moved its indicator from 0 to 100% while the audio never changed — worse than having no control at all. The capability is now probed (isVolumeControlSupported()inlib/platform.ts) rather than sniffed from the user agent, so desktop Safari is not caught. Where volume cannot be set:mutedis still settable on iOS, so muting keeps working)The audio pipeline is untouched. Worth noting for a possible follow-up: MP2 audio is software-decoded and played through
PCMAudioPlayer→ Web AudioGainNode, which iOS does not restrict. That path could support volume on iOS if it stopped slaving the gain tovideo.volume(pcm-audio-player.tsmirrors it onvolumechange). Out of scope here.src/embedded_web_data.his not included, per theCLAUDE.mdconvention that web UI rebuild output is not committed.Verification
Local mock upstreams via
tools/devlab/devlab.pyplus rtp2httpd, driven with synthetic touch pointer events at a 375×812 mobile viewport.→ → ←touch-action: auto, scrolling unaffectedVolume capability probe, checked against a
volumeaccessor patched to behave like iOS: returnstruenormally,falsewhen the setter is a no-op,trueagain once restored. The degraded UI was then exercised in a throwaway build with the probe forced tofalse(reverted before committing): slider absent, mute button present, and a vertical swipe on either half switches channels.One bug found and fixed during verification: a swipe usually produces no trailing click, so the click-suppression flag survived and swallowed the next legitimate tap. The flag is now cleared at the start of every
pointerdown.pnpm run lint:biomeandpnpm run type-check:tscboth pass.Not verified on a physical iOS device — the iOS behavior above is based on the documented WebKit restriction and the simulated probe.
🤖 Generated with Claude Code