From e80d8ca8937dc12d674803a2f9c76e449d645355 Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Fri, 7 Aug 2026 16:20:34 +0800 Subject: [PATCH 1/4] feat(web-ui): add touch swipe gestures to the web player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../player/player-gesture-overlay.tsx | 122 ++++++++ web-ui/src/components/player/video-player.tsx | 87 ++++-- web-ui/src/hooks/use-player-touch-gestures.ts | 260 ++++++++++++++++++ web-ui/src/i18n/player.ts | 9 + web-ui/src/pages/player.tsx | 15 + 5 files changed, 475 insertions(+), 18 deletions(-) create mode 100644 web-ui/src/components/player/player-gesture-overlay.tsx create mode 100644 web-ui/src/hooks/use-player-touch-gestures.ts diff --git a/web-ui/src/components/player/player-gesture-overlay.tsx b/web-ui/src/components/player/player-gesture-overlay.tsx new file mode 100644 index 00000000..10343d5e --- /dev/null +++ b/web-ui/src/components/player/player-gesture-overlay.tsx @@ -0,0 +1,122 @@ +import { clsx } from "clsx"; +import { ChevronDown, ChevronUp, FastForward, Rewind, Volume1, Volume2, VolumeX } from "lucide-react"; +import { useRef } from "react"; +import type { PlayerGestureIndicator } from "../../hooks/use-player-touch-gestures"; +import { usePlayerTranslation } from "../../hooks/use-player-translation"; +import type { Locale } from "../../lib/locale"; +import { PLAYER_OVERLAY_SURFACE_CLASS } from "./classnames"; +import { PlayerSelectedGlassLayers } from "./player-selected-glass-layers"; + +const ICON_CLASS = + "h-7 w-7 shrink-0 text-blue-100 drop-shadow-[0_0_14px_rgba(59,130,246,0.5)] md:h-9 md:w-9 [@container_video_(max-height:_320px)]:h-6 [@container_video_(max-height:_320px)]:w-6 md:[@container_video_(max-height:_320px)]:h-6 md:[@container_video_(max-height:_320px)]:w-6"; + +function formatSeekDelta(deltaSeconds: number): string { + const rounded = Math.round(deltaSeconds); + const sign = rounded < 0 ? "-" : "+"; + const absolute = Math.abs(rounded); + const minutes = Math.floor(absolute / 60); + const seconds = absolute % 60; + return `${sign}${minutes}:${String(seconds).padStart(2, "0")}`; +} + +function VolumeIndicator({ volume, label }: { volume: number; label: string }) { + const percent = Math.round(volume * 100); + return ( + <> + {volume <= 0 ? ( + + ) : volume < 0.5 ? ( + + ) : ( + + )} +
+
+
+ + {percent}% + + + ); +} + +function ChannelIndicator({ + indicator, + label, +}: { + indicator: Extract; + label: string; +}) { + const { direction, target } = indicator; + const Chevron = direction === "prev" ? ChevronUp : ChevronDown; + return ( + <> + + {target ? ( + <> + + {target.id} + + {target.name} + + ) : ( + {label} + )} + + ); +} + +export function PlayerGestureIndicatorOverlay({ + indicator, + locale, +}: { + indicator: PlayerGestureIndicator | null; + locale: Locale; +}) { + const t = usePlayerTranslation(locale); + // Keep the last indicator on screen while the card fades out, so the content does not + // blink away before the opacity transition finishes. + const lastIndicatorRef = useRef(null); + if (indicator) lastIndicatorRef.current = indicator; + const shown = indicator ?? lastIndicatorRef.current; + + return ( +
+
+ +
+ {shown?.kind === "volume" && } + {shown?.kind === "channel" && ( + + )} + {shown?.kind === "seek" && ( + <> + {shown.deltaSeconds < 0 ? ( + + ) : ( + + )} + + {formatSeekDelta(shown.deltaSeconds)} + + + )} +
+
+
+ ); +} diff --git a/web-ui/src/components/player/video-player.tsx b/web-ui/src/components/player/video-player.tsx index 7c753e9e..f0a30876 100644 --- a/web-ui/src/components/player/video-player.tsx +++ b/web-ui/src/components/player/video-player.tsx @@ -11,6 +11,7 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; +import { usePlayerTouchGestures } from "../../hooks/use-player-touch-gestures"; import { usePlayerTranslation } from "../../hooks/use-player-translation"; import { getDocumentPictureInPicture, @@ -49,6 +50,7 @@ import type { Channel, EPGProgram } from "../../types/player"; import type { PictureInPictureMode } from "../../types/ui"; import { PLAYER_OVERLAY_SURFACE_CLASS } from "./classnames"; import { PlayerControls } from "./player-controls"; +import { PlayerGestureIndicatorOverlay } from "./player-gesture-overlay"; import { PlayerSelectedGlassLayers } from "./player-selected-glass-layers"; interface VideoPlayerProps { @@ -64,6 +66,9 @@ interface VideoPlayerProps { streamStartTime: Date; onCurrentVideoTimeChange: (time: number) => void; onChannelNavigate?: (target: "prev" | "next" | number) => void; + /** Neighbours of the current channel, used to preview the target of a swipe-to-zap gesture. */ + prevChannel?: Channel | null; + nextChannel?: Channel | null; showSidebar?: boolean; onToggleSidebar?: () => void; isFullscreen: boolean; @@ -262,6 +267,8 @@ function VideoPlayerComponent({ streamStartTime, onCurrentVideoTimeChange, onChannelNavigate, + prevChannel = null, + nextChannel = null, showSidebar = true, onToggleSidebar, isFullscreen, @@ -526,24 +533,7 @@ function VideoPlayerComponent({ [hideControlsImmediately], ); - // Click / tap toggles controls. The handler lives on the whole player surface (not - // just the
+ {/* + Touch gesture layer: left half swipes zap channels, right half swipes set volume, + horizontal swipes seek, double tap toggles playback. It sits above the video but + below every overlay (z-10 / z-20), so visible controls keep priority. `touch-none` + stays scoped to this element on purpose — putting it on the surface would inherit + down into the settings popover and break its scrolling. + */} + {!needsUserInteraction && !error && ( + )} + + {channel && !error && !needsUserInteraction && ( + + )} ); diff --git a/web-ui/src/hooks/use-player-touch-gestures.ts b/web-ui/src/hooks/use-player-touch-gestures.ts new file mode 100644 index 00000000..12591637 --- /dev/null +++ b/web-ui/src/hooks/use-player-touch-gestures.ts @@ -0,0 +1,260 @@ +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useEffectEvent, + useRef, + useState, +} from "react"; +import type { Channel } from "../types/player"; + +/** Movement needed before a gesture direction is locked in. Below this a touch is still a tap. */ +const ACTIVATION_THRESHOLD_PX = 12; +/** Vertical travel needed to commit a channel switch, as a ratio of the surface height. */ +const CHANNEL_COMMIT_RATIO = 0.15; +const CHANNEL_COMMIT_MIN_PX = 48; +const CHANNEL_COMMIT_MAX_PX = 120; +/** Vertical travel that spans the whole 0..1 volume range, as a ratio of the surface height. */ +const VOLUME_FULL_SWING_RATIO = 0.6; +/** Seconds seeked when dragging across the full surface width. */ +const SEEK_FULL_SWING_SECONDS = 120; +/** Ignore sub-second seeks so a sloppy tap-drag does not nudge playback. */ +const SEEK_MIN_COMMIT_SECONDS = 1; +const DOUBLE_TAP_MS = 300; +const DOUBLE_TAP_SLOP_PX = 40; +/** How long the indicator lingers after the finger lifts. */ +const INDICATOR_LINGER_MS = 700; + +export type PlayerGestureIndicator = + | { kind: "volume"; volume: number } + | { kind: "channel"; direction: "prev" | "next"; target: Channel | null } + | { kind: "seek"; deltaSeconds: number }; + +type GestureMode = "pending" | "channel" | "volume" | "seek"; + +interface GestureState { + pointerId: number; + mode: GestureMode; + startX: number; + startY: number; + width: number; + height: number; + startVolume: number; + /** Direction armed by the channel gesture, or null while below the commit threshold. */ + channelDirection: "prev" | "next" | null; + seekDeltaSeconds: number; +} + +interface UsePlayerTouchGesturesOptions { + /** Disable everything (no channel selected, error overlay showing, ...). */ + enabled: boolean; + volume: number; + isMuted: boolean; + prevChannel: Channel | null; + nextChannel: Channel | null; + onVolumeChange: (volume: number) => void; + onChannelNavigate: ((target: "prev" | "next") => void) | undefined; + onRelativeSeek: (deltaSeconds: number) => void; + onTogglePlayPause: () => void; + onShowControls: () => void; +} + +function clamp01(value: number): number { + return Math.min(Math.max(value, 0), 1); +} + +/** + * Touch-only gesture layer for the player surface, modeled after native IPTV apps: + * vertical drag on the left half switches channels, on the right half adjusts volume, + * horizontal drag seeks (relative, in live and catchup alike — same as the arrow keys), + * and a double tap toggles playback. + * + * Channel switching and seeking only commit on release so a half-swipe can be aborted; + * volume tracks the finger live because it is cheap and instantly reversible. + */ +export function usePlayerTouchGestures({ + enabled, + volume, + isMuted, + prevChannel, + nextChannel, + onVolumeChange, + onChannelNavigate, + onRelativeSeek, + onTogglePlayPause, + onShowControls, +}: UsePlayerTouchGesturesOptions) { + const gestureRef = useRef(null); + const lastTapRef = useRef<{ time: number; x: number; y: number } | null>(null); + /** Set when a real (non-tap) gesture ends, so the trailing click does not toggle the controls. */ + const suppressClickRef = useRef(false); + const indicatorTimeoutRef = useRef(0); + const [indicator, setIndicator] = useState(null); + + useEffect(() => { + return () => { + if (indicatorTimeoutRef.current) window.clearTimeout(indicatorTimeoutRef.current); + }; + }, []); + + const showIndicator = useCallback((next: PlayerGestureIndicator | null) => { + if (indicatorTimeoutRef.current) { + window.clearTimeout(indicatorTimeoutRef.current); + indicatorTimeoutRef.current = 0; + } + setIndicator(next); + }, []); + + const fadeIndicator = useCallback(() => { + if (indicatorTimeoutRef.current) window.clearTimeout(indicatorTimeoutRef.current); + indicatorTimeoutRef.current = window.setTimeout(() => { + indicatorTimeoutRef.current = 0; + setIndicator(null); + }, INDICATOR_LINGER_MS); + }, []); + + const handleTap = useEffectEvent((clientX: number, clientY: number) => { + const now = Date.now(); + const lastTap = lastTapRef.current; + const isDoubleTap = + lastTap !== null && + now - lastTap.time <= DOUBLE_TAP_MS && + Math.abs(clientX - lastTap.x) <= DOUBLE_TAP_SLOP_PX && + Math.abs(clientY - lastTap.y) <= DOUBLE_TAP_SLOP_PX; + + if (isDoubleTap) { + lastTapRef.current = null; + suppressClickRef.current = true; + onTogglePlayPause(); + onShowControls(); + return; + } + + lastTapRef.current = { time: now, x: clientX, y: clientY }; + }); + + const handlePointerDown = useEffectEvent((event: ReactPointerEvent) => { + // A drag usually produces no trailing click at all, so the suppression flag would + // otherwise survive and swallow the *next* legitimate tap. Any new pointer sequence + // starts after that click would have fired, so it is always safe to clear here. + suppressClickRef.current = false; + + if (!enabled || event.pointerType !== "touch") return; + // Only the first finger drives a gesture; extra pointers (pinch) are ignored. + if (!event.isPrimary || gestureRef.current !== null) return; + + const rect = event.currentTarget.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + + event.currentTarget.setPointerCapture(event.pointerId); + gestureRef.current = { + pointerId: event.pointerId, + mode: "pending", + startX: event.clientX, + startY: event.clientY, + width: rect.width, + height: rect.height, + startVolume: isMuted ? 0 : volume, + channelDirection: null, + seekDeltaSeconds: 0, + }; + }); + + const handlePointerMove = useEffectEvent((event: ReactPointerEvent) => { + const gesture = gestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + + const dx = event.clientX - gesture.startX; + const dy = event.clientY - gesture.startY; + + if (gesture.mode === "pending") { + if (Math.max(Math.abs(dx), Math.abs(dy)) < ACTIVATION_THRESHOLD_PX) return; + gesture.mode = Math.abs(dy) > Math.abs(dx) ? (gesture.startX < gesture.width / 2 ? "channel" : "volume") : "seek"; + // A drag is never a tap; drop any pending double-tap candidate. + lastTapRef.current = null; + } + + if (gesture.mode === "volume") { + // Up is louder, hence the negated dy. + const nextVolume = clamp01(gesture.startVolume - dy / (gesture.height * VOLUME_FULL_SWING_RATIO)); + onVolumeChange(nextVolume); + showIndicator({ kind: "volume", volume: nextVolume }); + return; + } + + if (gesture.mode === "channel") { + const threshold = Math.min( + Math.max(gesture.height * CHANNEL_COMMIT_RATIO, CHANNEL_COMMIT_MIN_PX), + CHANNEL_COMMIT_MAX_PX, + ); + // Swiping up walks the list backwards, matching the ArrowUp = prev keyboard shortcut. + const direction = Math.abs(dy) < threshold ? null : dy < 0 ? "prev" : "next"; + if (direction === gesture.channelDirection) return; + gesture.channelDirection = direction; + showIndicator( + direction === null + ? null + : { kind: "channel", direction, target: direction === "prev" ? prevChannel : nextChannel }, + ); + return; + } + + if (gesture.mode === "seek") { + const deltaSeconds = (dx / gesture.width) * SEEK_FULL_SWING_SECONDS; + gesture.seekDeltaSeconds = deltaSeconds; + showIndicator({ kind: "seek", deltaSeconds }); + } + }); + + const handlePointerUp = useEffectEvent((event: ReactPointerEvent) => { + const gesture = gestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + gestureRef.current = null; + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + if (gesture.mode === "pending") { + handleTap(event.clientX, event.clientY); + return; + } + + suppressClickRef.current = true; + + if (gesture.mode === "channel" && gesture.channelDirection) { + onChannelNavigate?.(gesture.channelDirection); + } else if (gesture.mode === "seek" && Math.abs(gesture.seekDeltaSeconds) >= SEEK_MIN_COMMIT_SECONDS) { + onRelativeSeek(gesture.seekDeltaSeconds); + } + + fadeIndicator(); + }); + + const handlePointerCancel = useEffectEvent((event: ReactPointerEvent) => { + const gesture = gestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + gestureRef.current = null; + // Volume already applied live and is not rolled back; channel/seek simply never commit. + if (gesture.mode !== "pending") suppressClickRef.current = true; + fadeIndicator(); + }); + + /** Consumes the one-shot flag: true when the click that follows a gesture must be ignored. */ + const consumeSuppressedClick = useCallback(() => { + if (!suppressClickRef.current) return false; + suppressClickRef.current = false; + return true; + }, []); + + return { + indicator, + consumeSuppressedClick, + gestureHandlers: { + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerUp, + onPointerCancel: handlePointerCancel, + }, + }; +} diff --git a/web-ui/src/i18n/player.ts b/web-ui/src/i18n/player.ts index 22aee271..fecce201 100644 --- a/web-ui/src/i18n/player.ts +++ b/web-ui/src/i18n/player.ts @@ -84,6 +84,9 @@ const base: TranslationDict = { pause: "Pause", mute: "Mute", unmute: "Unmute", + volume: "Volume", + previousChannel: "Previous channel", + nextChannel: "Next channel", fullscreen: "Fullscreen", exitFullscreen: "Exit Fullscreen", pictureInPicture: "Picture in Picture", @@ -210,6 +213,9 @@ const zhHans: TranslationDict = { pause: "暂停", mute: "静音", unmute: "取消静音", + volume: "音量", + previousChannel: "上一个频道", + nextChannel: "下一个频道", fullscreen: "全屏", exitFullscreen: "退出全屏", pictureInPicture: "画中画", @@ -337,6 +343,9 @@ const zhHant: TranslationDict = { pause: "暫停", mute: "靜音", unmute: "取消靜音", + volume: "音量", + previousChannel: "上一個頻道", + nextChannel: "下一個頻道", fullscreen: "全屏", exitFullscreen: "退出全屏", pictureInPicture: "畫中畫", diff --git a/web-ui/src/pages/player.tsx b/web-ui/src/pages/player.tsx index 0dbc2b82..865ace00 100644 --- a/web-ui/src/pages/player.tsx +++ b/web-ui/src/pages/player.tsx @@ -363,6 +363,19 @@ function PlayerPage() { [metadata, currentChannel, selectChannel], ); + // Neighbours of the current channel, so the player can preview the target of a + // swipe-to-zap gesture. Wraps around exactly like handleChannelNavigate. + const [prevChannel, nextChannel] = useMemo<[Channel | null, Channel | null]>(() => { + const channels = metadata?.channels; + if (!channels?.length || !currentChannel) return [null, null]; + const currentIndex = channels.indexOf(currentChannel); + if (currentIndex < 0) return [null, null]; + return [ + channels[currentIndex > 0 ? currentIndex - 1 : channels.length - 1], + channels[currentIndex < channels.length - 1 ? currentIndex + 1 : 0], + ]; + }, [metadata, currentChannel]); + const loadPlaylist = useCallback(async () => { try { setIsLoading(true); @@ -600,6 +613,8 @@ function PlayerPage() { streamStartTime={streamStartTime} onCurrentVideoTimeChange={handleCurrentVideoTimeChange} onChannelNavigate={handleChannelNavigate} + prevChannel={prevChannel} + nextChannel={nextChannel} showSidebar={showSidebar} onToggleSidebar={handleToggleSidebar} isFullscreen={isFullscreen} From 39afd3d9f01396158d6146140d6765b0c208aee5 Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Fri, 7 Aug 2026 16:28:44 +0800 Subject: [PATCH 2/4] fix(web-ui): disable seek entirely on channels without catchup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web-ui/src/components/player/video-player.tsx | 11 ++++++++--- web-ui/src/hooks/use-player-touch-gestures.ts | 17 +++++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/web-ui/src/components/player/video-player.tsx b/web-ui/src/components/player/video-player.tsx index f0a30876..365cd502 100644 --- a/web-ui/src/components/player/video-player.tsx +++ b/web-ui/src/components/player/video-player.tsx @@ -284,9 +284,12 @@ function VideoPlayerComponent({ const t = usePlayerTranslation(locale); const playbackBackendKind = getPlaybackBackendKind(); const currentVideoTimeRef = useRef(0); - const canSeekProgramInMediaSession = Boolean( - currentProgram && channel?.sources.some((source) => source.catchup && source.catchupSource), - ); + // A channel with no catchup source has nothing to seek into: a target outside the MSE + // buffer falls back to rebuilding the stream at the live edge, which is just a dropped + // connection with no seek to show for it. Seeking is therefore off across every entry + // point — the timeline in PlayerControls gates on the same expression. + const isCatchupSupported = Boolean(channel?.sources.some((source) => source.catchup && source.catchupSource)); + const canSeekProgramInMediaSession = Boolean(currentProgram) && isCatchupSupported; const canNavigateChannelsInMediaSession = Boolean(channel && onChannelNavigate); const playerDockRef = useRef(null); @@ -397,6 +400,7 @@ function VideoPlayerComponent({ }, [isLoading]); const handleRelativeSeek = useEffectEvent((deltaSeconds: number) => { + if (!isCatchupSupported) return; const activePlayer = getActivePlayer(); if (!activePlayer) return; const state = activePlayer.getState(); @@ -1536,6 +1540,7 @@ function VideoPlayerComponent({ gestureHandlers, } = usePlayerTouchGestures({ enabled: Boolean(channel) && !error && !needsUserInteraction, + enableSeekGesture: isCatchupSupported, volume, isMuted, prevChannel, diff --git a/web-ui/src/hooks/use-player-touch-gestures.ts b/web-ui/src/hooks/use-player-touch-gestures.ts index 12591637..647dd78b 100644 --- a/web-ui/src/hooks/use-player-touch-gestures.ts +++ b/web-ui/src/hooks/use-player-touch-gestures.ts @@ -30,7 +30,8 @@ export type PlayerGestureIndicator = | { kind: "channel"; direction: "prev" | "next"; target: Channel | null } | { kind: "seek"; deltaSeconds: number }; -type GestureMode = "pending" | "channel" | "volume" | "seek"; +/** "none" is a locked-in direction with nothing to do — it still swallows the trailing click. */ +type GestureMode = "pending" | "none" | "channel" | "volume" | "seek"; interface GestureState { pointerId: number; @@ -48,6 +49,8 @@ interface GestureState { interface UsePlayerTouchGesturesOptions { /** Disable everything (no channel selected, error overlay showing, ...). */ enabled: boolean; + /** Horizontal seek gesture; off for channels with no catchup source. */ + enableSeekGesture: boolean; volume: number; isMuted: boolean; prevChannel: Channel | null; @@ -66,14 +69,14 @@ function clamp01(value: number): number { /** * Touch-only gesture layer for the player surface, modeled after native IPTV apps: * vertical drag on the left half switches channels, on the right half adjusts volume, - * horizontal drag seeks (relative, in live and catchup alike — same as the arrow keys), - * and a double tap toggles playback. + * horizontal drag seeks, and a double tap toggles playback. * * Channel switching and seeking only commit on release so a half-swipe can be aborted; * volume tracks the finger live because it is cheap and instantly reversible. */ export function usePlayerTouchGestures({ enabled, + enableSeekGesture, volume, isMuted, prevChannel, @@ -169,7 +172,13 @@ export function usePlayerTouchGestures({ if (gesture.mode === "pending") { if (Math.max(Math.abs(dx), Math.abs(dy)) < ACTIVATION_THRESHOLD_PX) return; - gesture.mode = Math.abs(dy) > Math.abs(dx) ? (gesture.startX < gesture.width / 2 ? "channel" : "volume") : "seek"; + if (Math.abs(dy) > Math.abs(dx)) { + gesture.mode = gesture.startX < gesture.width / 2 ? "channel" : "volume"; + } else { + // Horizontal stays locked in even when seeking is unavailable, so the finger + // cannot slide into a vertical gesture halfway through the drag. + gesture.mode = enableSeekGesture ? "seek" : "none"; + } // A drag is never a tap; drop any pending double-tap candidate. lastTapRef.current = null; } From 5802583398a21dc85dc91ea44a27c2f0007126e1 Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Fri, 7 Aug 2026 16:48:40 +0800 Subject: [PATCH 3/4] fix(web-ui): hide volume controls where the platform ignores volume writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/player/player-controls.tsx | 48 +++++++++++-------- web-ui/src/components/player/video-player.tsx | 4 ++ web-ui/src/hooks/use-player-touch-gestures.ts | 15 ++++-- web-ui/src/lib/platform.ts | 19 ++++++++ 4 files changed, 61 insertions(+), 25 deletions(-) diff --git a/web-ui/src/components/player/player-controls.tsx b/web-ui/src/components/player/player-controls.tsx index d9466bda..92638278 100644 --- a/web-ui/src/components/player/player-controls.tsx +++ b/web-ui/src/components/player/player-controls.tsx @@ -50,6 +50,8 @@ interface PlayerControlsProps { onPlayPause: () => void; volume: number; onVolumeChange: (volume: number) => void; + /** False where the platform makes volume read-only (iOS): the slider is hidden, mute stays. */ + canControlVolume: boolean; isMuted: boolean; onMuteToggle: () => void; onFullscreen: () => void; @@ -391,6 +393,7 @@ function PlayerControlsComponent({ onPlayPause, volume, onVolumeChange, + canControlVolume, isMuted, onMuteToggle, onFullscreen, @@ -470,27 +473,30 @@ function PlayerControlsComponent({ )} - {/* Volume Slider */} -
- - onVolumeChange(parseFloat(e.target.value))} - className="relative z-10 m-0 block h-16 w-1 cursor-pointer appearance-none bg-transparent [writing-mode:vertical-lr] [direction:rtl] md:h-20" - style={{ - background: `linear-gradient(to top, #3b82f6 0%, #6366f1 ${(isMuted ? 0 : volume) * 100}%, rgba(219,234,254,0.18) ${(isMuted ? 0 : volume) * 100}%, rgba(219,234,254,0.18) 100%)`, - }} - /> -
+ {/* Volume Slider — omitted where the platform ignores volume writes (iOS), + since it would move without changing anything. Mute still works there. */} + {canControlVolume && ( +
+ + onVolumeChange(parseFloat(e.target.value))} + className="relative z-10 m-0 block h-16 w-1 cursor-pointer appearance-none bg-transparent [writing-mode:vertical-lr] [direction:rtl] md:h-20" + style={{ + background: `linear-gradient(to top, #3b82f6 0%, #6366f1 ${(isMuted ? 0 : volume) * 100}%, rgba(219,234,254,0.18) ${(isMuted ? 0 : volume) * 100}%, rgba(219,234,254,0.18) 100%)`, + }} + /> +
+ )} diff --git a/web-ui/src/components/player/video-player.tsx b/web-ui/src/components/player/video-player.tsx index 365cd502..544aeb39 100644 --- a/web-ui/src/components/player/video-player.tsx +++ b/web-ui/src/components/player/video-player.tsx @@ -23,6 +23,7 @@ import { } from "../../lib/document-picture-in-picture"; import type { Locale } from "../../lib/locale"; import { buildCatchupSegments } from "../../lib/m3u-parser"; +import { isVolumeControlSupported } from "../../lib/platform"; import { getMuted, getVolume, saveMuted, saveVolume } from "../../lib/player-storage"; import { createProgramTimeline, programPositionToWallClock } from "../../lib/program-timeline"; import { @@ -290,6 +291,7 @@ function VideoPlayerComponent({ // point — the timeline in PlayerControls gates on the same expression. const isCatchupSupported = Boolean(channel?.sources.some((source) => source.catchup && source.catchupSource)); const canSeekProgramInMediaSession = Boolean(currentProgram) && isCatchupSupported; + const canControlVolume = isVolumeControlSupported(); const canNavigateChannelsInMediaSession = Boolean(channel && onChannelNavigate); const playerDockRef = useRef(null); @@ -1541,6 +1543,7 @@ function VideoPlayerComponent({ } = usePlayerTouchGestures({ enabled: Boolean(channel) && !error && !needsUserInteraction, enableSeekGesture: isCatchupSupported, + enableVolumeGesture: canControlVolume, volume, isMuted, prevChannel, @@ -2024,6 +2027,7 @@ function VideoPlayerComponent({ onPlayPause={togglePlayPause} volume={volume} onVolumeChange={handleVolumeChange} + canControlVolume={canControlVolume} isMuted={isMuted} onMuteToggle={handleMuteToggle} onFullscreen={handleFullscreen} diff --git a/web-ui/src/hooks/use-player-touch-gestures.ts b/web-ui/src/hooks/use-player-touch-gestures.ts index 647dd78b..bcc48d4b 100644 --- a/web-ui/src/hooks/use-player-touch-gestures.ts +++ b/web-ui/src/hooks/use-player-touch-gestures.ts @@ -51,6 +51,8 @@ interface UsePlayerTouchGesturesOptions { enabled: boolean; /** Horizontal seek gesture; off for channels with no catchup source. */ enableSeekGesture: boolean; + /** Right-half volume gesture; off where the platform makes volume read-only (iOS). */ + enableVolumeGesture: boolean; volume: number; isMuted: boolean; prevChannel: Channel | null; @@ -69,7 +71,8 @@ function clamp01(value: number): number { /** * Touch-only gesture layer for the player surface, modeled after native IPTV apps: * vertical drag on the left half switches channels, on the right half adjusts volume, - * horizontal drag seeks, and a double tap toggles playback. + * horizontal drag seeks, and a double tap toggles playback. Where volume is read-only + * (iOS), zapping claims the full width instead of half. * * Channel switching and seeking only commit on release so a half-swipe can be aborted; * volume tracks the finger live because it is cheap and instantly reversible. @@ -77,6 +80,7 @@ function clamp01(value: number): number { export function usePlayerTouchGestures({ enabled, enableSeekGesture, + enableVolumeGesture, volume, isMuted, prevChannel, @@ -172,11 +176,14 @@ export function usePlayerTouchGestures({ if (gesture.mode === "pending") { if (Math.max(Math.abs(dx), Math.abs(dy)) < ACTIVATION_THRESHOLD_PX) return; + // A direction always locks in, even when the gesture it maps to is unavailable, so + // the finger cannot slide into a different gesture halfway through the drag. if (Math.abs(dy) > Math.abs(dx)) { - gesture.mode = gesture.startX < gesture.width / 2 ? "channel" : "volume"; + // With no volume gesture to share the surface with, zapping takes the full width + // rather than leaving the right half inert. + const isChannelHalf = !enableVolumeGesture || gesture.startX < gesture.width / 2; + gesture.mode = isChannelHalf ? "channel" : "volume"; } else { - // Horizontal stays locked in even when seeking is unavailable, so the finger - // cannot slide into a vertical gesture halfway through the drag. gesture.mode = enableSeekGesture ? "seek" : "none"; } // A drag is never a tap; drop any pending double-tap candidate. diff --git a/web-ui/src/lib/platform.ts b/web-ui/src/lib/platform.ts index 639fd186..32f273b7 100644 --- a/web-ui/src/lib/platform.ts +++ b/web-ui/src/lib/platform.ts @@ -16,3 +16,22 @@ export function isLGWebOS(): boolean { export function isDesktopDevice(): boolean { return document.documentElement.dataset.playerPlatform === "desktop"; } + +let volumeControlSupported: boolean | null = null; + +/** + * Whether `HTMLMediaElement.volume` can actually be changed. + * + * iOS and iPadOS make it read-only — assignment is silently ignored, reads always + * return 1, and no `volumechange` fires — because the volume belongs to the hardware + * buttons there. `muted` stays settable, so muting still works. Probed rather than + * sniffed from the user agent so desktop Safari, which does support it, is not caught. + */ +export function isVolumeControlSupported(): boolean { + if (volumeControlSupported === null) { + const probe = document.createElement("video"); + probe.volume = 0.5; + volumeControlSupported = probe.volume === 0.5; + } + return volumeControlSupported; +} From d528c835dff8512007a91b34fdb5626bcee37367 Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Fri, 7 Aug 2026 17:06:30 +0800 Subject: [PATCH 4/4] fix(web-ui): address review findings on the gesture layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../player/player-gesture-overlay.tsx | 49 +++++++++++-------- web-ui/src/hooks/use-player-touch-gestures.ts | 28 +++++++++-- web-ui/src/i18n/player.ts | 3 -- web-ui/src/lib/platform.ts | 21 +++----- 4 files changed, 62 insertions(+), 39 deletions(-) diff --git a/web-ui/src/components/player/player-gesture-overlay.tsx b/web-ui/src/components/player/player-gesture-overlay.tsx index 10343d5e..8d0ea664 100644 --- a/web-ui/src/components/player/player-gesture-overlay.tsx +++ b/web-ui/src/components/player/player-gesture-overlay.tsx @@ -1,12 +1,15 @@ import { clsx } from "clsx"; import { ChevronDown, ChevronUp, FastForward, Rewind, Volume1, Volume2, VolumeX } from "lucide-react"; -import { useRef } from "react"; +import { useEffect, useState } from "react"; import type { PlayerGestureIndicator } from "../../hooks/use-player-touch-gestures"; import { usePlayerTranslation } from "../../hooks/use-player-translation"; import type { Locale } from "../../lib/locale"; import { PLAYER_OVERLAY_SURFACE_CLASS } from "./classnames"; import { PlayerSelectedGlassLayers } from "./player-selected-glass-layers"; +/** Must match the card's `duration-200` opacity transition. */ +const FADE_OUT_MS = 200; + const ICON_CLASS = "h-7 w-7 shrink-0 text-blue-100 drop-shadow-[0_0_14px_rgba(59,130,246,0.5)] md:h-9 md:w-9 [@container_video_(max-height:_320px)]:h-6 [@container_video_(max-height:_320px)]:w-6 md:[@container_video_(max-height:_320px)]:h-6 md:[@container_video_(max-height:_320px)]:w-6"; @@ -19,16 +22,16 @@ function formatSeekDelta(deltaSeconds: number): string { return `${sign}${minutes}:${String(seconds).padStart(2, "0")}`; } -function VolumeIndicator({ volume, label }: { volume: number; label: string }) { +function VolumeIndicator({ volume }: { volume: number }) { const percent = Math.round(volume * 100); return ( <> {volume <= 0 ? ( - + ) : volume < 0.5 ? ( - + ) : ( - + )}
; + /** Shown in place of the channel name when the neighbour is not known yet. */ label: string; }) { const { direction, target } = indicator; const Chevron = direction === "prev" ? ChevronUp : ChevronDown; return ( <> - + {target ? ( <> @@ -77,26 +81,35 @@ export function PlayerGestureIndicatorOverlay({ locale: Locale; }) { const t = usePlayerTranslation(locale); - // Keep the last indicator on screen while the card fades out, so the content does not - // blink away before the opacity transition finishes. - const lastIndicatorRef = useRef(null); - if (indicator) lastIndicatorRef.current = indicator; - const shown = indicator ?? lastIndicatorRef.current; + // Hold the last indicator while the card fades out so the content does not blink away + // mid-transition, then drop it so no stale text is left sitting in the DOM. + const [shown, setShown] = useState(indicator); + + useEffect(() => { + if (indicator) { + setShown(indicator); + return; + } + const timeoutId = window.setTimeout(() => setShown(null), FADE_OUT_MS); + return () => window.clearTimeout(timeoutId); + }, [indicator]); return ( -
+ // Decorative: a visual echo of a gesture the user just performed. Not exposed to + // assistive tech — the volume readout changes on every pointermove, so a live region + // would spam announcements, and screen readers consume swipes before they reach us. + // The underlying state stays available on the labelled controls in the control bar. +