Skip to content

feat(web-ui): add touch swipe gestures to the web player - #705

Merged
stackia merged 4 commits into
mainfrom
feat/player-touch-gestures
Aug 7, 2026
Merged

feat(web-ui): add touch swipe gestures to the web player#705
stackia merged 4 commits into
mainfrom
feat/player-touch-gestures

Conversation

@stackia

@stackia stackia commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Closes #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

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.
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Documentation preview

The documentation preview has been deployed for this pull request.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread web-ui/src/hooks/use-player-touch-gestures.ts Outdated
Comment thread web-ui/src/hooks/use-player-touch-gestures.ts
Comment thread web-ui/src/components/player/player-gesture-overlay.tsx Outdated
stackia added 3 commits August 7, 2026 16:28
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.
@stackia
stackia merged commit befe56c into main Aug 7, 2026
11 checks passed
@stackia
stackia deleted the feat/player-touch-gestures branch August 7, 2026 09:12
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

功能需求:内置网页播放器能否实现触摸滑动调音量和换台

1 participant