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/player-gesture-overlay.tsx b/web-ui/src/components/player/player-gesture-overlay.tsx
new file mode 100644
index 00000000..8d0ea664
--- /dev/null
+++ b/web-ui/src/components/player/player-gesture-overlay.tsx
@@ -0,0 +1,131 @@
+import { clsx } from "clsx";
+import { ChevronDown, ChevronUp, FastForward, Rewind, Volume1, Volume2, VolumeX } from "lucide-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";
+
+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 }: { volume: number }) {
+ const percent = Math.round(volume * 100);
+ return (
+ <>
+ {volume <= 0 ? (
+
+ ) : volume < 0.5 ? (
+
+ ) : (
+
+ )}
+
+
+ {percent}%
+
+ >
+ );
+}
+
+function ChannelIndicator({
+ indicator,
+ label,
+}: {
+ indicator: Extract;
+ /** 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 ? (
+ <>
+
+ {target.id}
+
+ {target.name}
+ >
+ ) : (
+ {label}
+ )}
+ >
+ );
+}
+
+export function PlayerGestureIndicatorOverlay({
+ indicator,
+ locale,
+}: {
+ indicator: PlayerGestureIndicator | null;
+ locale: Locale;
+}) {
+ const t = usePlayerTranslation(locale);
+ // 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.
+
+
+
+
+ {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..544aeb39 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,
@@ -22,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 {
@@ -49,6 +51,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 +67,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 +268,8 @@ function VideoPlayerComponent({
streamStartTime,
onCurrentVideoTimeChange,
onChannelNavigate,
+ prevChannel = null,
+ nextChannel = null,
showSidebar = true,
onToggleSidebar,
isFullscreen,
@@ -277,9 +285,13 @@ 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 canControlVolume = isVolumeControlSupported();
const canNavigateChannelsInMediaSession = Boolean(channel && onChannelNavigate);
const playerDockRef = useRef(null);
@@ -390,6 +402,7 @@ function VideoPlayerComponent({
}, [isLoading]);
const handleRelativeSeek = useEffectEvent((deltaSeconds: number) => {
+ if (!isCatchupSupported) return;
const activePlayer = getActivePlayer();
if (!activePlayer) return;
const state = activePlayer.getState();
@@ -526,24 +539,7 @@ function VideoPlayerComponent({
[hideControlsImmediately],
);
- // Click / tap toggles controls. The handler lives on the whole player surface (not
- // just the ) so taps on the letterbox bars outside the 16:9 frame — common on
- // desktop/tablet where the surface is taller/wider than the video — toggle too. We
- // only act when the click lands on the surface itself or the video element; overlays
- // (toolbar buttons, channel info) sit above and own their own clicks, so a click that
- // bubbles up from them is ignored and never dismisses the controls.
- const handleSurfaceClick = useCallback(
- (event: ReactMouseEvent) => {
- const target = event.target as HTMLElement;
- if (target !== event.currentTarget && target.tagName !== "VIDEO") return;
- if (showControls) {
- hideControlsImmediately();
- } else {
- showControlsImmediately();
- }
- },
- [showControls, hideControlsImmediately, showControlsImmediately],
- );
+ // `handleSurfaceClick` lives further down, next to the touch-gesture wiring it depends on.
// Start auto-hide timer on mount
useEffect(() => {
@@ -1540,6 +1536,49 @@ function VideoPlayerComponent({
}
});
+ const {
+ indicator: gestureIndicator,
+ consumeSuppressedClick,
+ gestureHandlers,
+ } = usePlayerTouchGestures({
+ enabled: Boolean(channel) && !error && !needsUserInteraction,
+ enableSeekGesture: isCatchupSupported,
+ enableVolumeGesture: canControlVolume,
+ volume,
+ isMuted,
+ prevChannel,
+ nextChannel,
+ onVolumeChange: handleVolumeChange,
+ onChannelNavigate,
+ onRelativeSeek: handleRelativeSeek,
+ onTogglePlayPause: togglePlayPause,
+ onShowControls: showControlsImmediately,
+ });
+
+ // Click / tap toggles controls. The handler lives on the whole player surface (not
+ // just the ) so taps on the letterbox bars outside the 16:9 frame — common on
+ // desktop/tablet where the surface is taller/wider than the video — toggle too. We
+ // only act when the click lands on the surface itself, the video element, or the
+ // transparent gesture layer that covers both; overlays (toolbar buttons, channel info)
+ // sit above and own their own clicks, so a click that bubbles up from them is ignored
+ // and never dismisses the controls. A click that trails a completed touch gesture is
+ // swallowed as well.
+ const handleSurfaceClick = useCallback(
+ (event: ReactMouseEvent) => {
+ const target = event.target as HTMLElement;
+ if (target !== event.currentTarget && target.tagName !== "VIDEO" && !("playerSurfaceHit" in target.dataset)) {
+ return;
+ }
+ if (consumeSuppressedClick()) return;
+ if (showControls) {
+ hideControlsImmediately();
+ } else {
+ showControlsImmediately();
+ }
+ },
+ [showControls, hideControlsImmediately, showControlsImmediately, consumeSuppressedClick],
+ );
+
const exitPictureInPicture = useEffectEvent(async (): Promise => {
const documentPictureInPicture = getDocumentPictureInPicture();
const pipWindow = documentPictureInPicture?.window ?? documentPiPWindowRef.current;
@@ -1773,6 +1812,22 @@ function VideoPlayerComponent({
))}
+ {/*
+ 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 && (
+
+ )}
+
{!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..a4afc144
--- /dev/null
+++ b/web-ui/src/hooks/use-player-touch-gestures.ts
@@ -0,0 +1,298 @@
+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 };
+
+/** "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;
+ /** Viewport coordinates — only ever used as deltas against later move events. */
+ startX: number;
+ startY: number;
+ mode: GestureMode;
+ /** Resolved at pointerdown, where the rect is in hand, so it is not mixed up with viewport x. */
+ startedOnLeftHalf: boolean;
+ 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;
+ /** 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;
+ 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, 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.
+ */
+export function usePlayerTouchGestures({
+ enabled,
+ enableSeekGesture,
+ enableVolumeGesture,
+ 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);
+ };
+ }, []);
+
+ // The gesture layer unmounts when playback errors out or needs a user gesture. A finger
+ // still down at that moment gets no pointerup or pointercancel — React has already torn
+ // the handlers down — so an in-flight gesture would linger in the ref and reject every
+ // later touch, since the whole hook survives channel switches.
+ useEffect(() => {
+ if (enabled) return;
+ gestureRef.current = null;
+ lastTapRef.current = null;
+ if (indicatorTimeoutRef.current) {
+ window.clearTimeout(indicatorTimeoutRef.current);
+ indicatorTimeoutRef.current = 0;
+ }
+ setIndicator(null);
+ }, [enabled]);
+
+ 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,
+ startedOnLeftHalf: event.clientX - rect.left < rect.width / 2,
+ 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;
+ // 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)) {
+ // With no volume gesture to share the surface with, zapping takes the full width
+ // rather than leaving the right half inert.
+ gesture.mode = !enableVolumeGesture || gesture.startedOnLeftHalf ? "channel" : "volume";
+ } else {
+ gesture.mode = enableSeekGesture ? "seek" : "none";
+ }
+ // 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,
+ // Capture can be lost without a pointerup (scroll takeover, element reflow). Safe to
+ // route here: pointerup clears the ref before releasing, so its own lostpointercapture
+ // finds nothing to cancel.
+ onLostPointerCapture: handlePointerCancel,
+ },
+ };
+}
diff --git a/web-ui/src/i18n/player.ts b/web-ui/src/i18n/player.ts
index 22aee271..46c4d918 100644
--- a/web-ui/src/i18n/player.ts
+++ b/web-ui/src/i18n/player.ts
@@ -84,6 +84,8 @@ const base: TranslationDict = {
pause: "Pause",
mute: "Mute",
unmute: "Unmute",
+ previousChannel: "Previous channel",
+ nextChannel: "Next channel",
fullscreen: "Fullscreen",
exitFullscreen: "Exit Fullscreen",
pictureInPicture: "Picture in Picture",
@@ -210,6 +212,8 @@ const zhHans: TranslationDict = {
pause: "暂停",
mute: "静音",
unmute: "取消静音",
+ previousChannel: "上一个频道",
+ nextChannel: "下一个频道",
fullscreen: "全屏",
exitFullscreen: "退出全屏",
pictureInPicture: "画中画",
@@ -337,6 +341,8 @@ const zhHant: TranslationDict = {
pause: "暫停",
mute: "靜音",
unmute: "取消靜音",
+ previousChannel: "上一個頻道",
+ nextChannel: "下一個頻道",
fullscreen: "全屏",
exitFullscreen: "退出全屏",
pictureInPicture: "畫中畫",
diff --git a/web-ui/src/lib/platform.ts b/web-ui/src/lib/platform.ts
index 639fd186..34165662 100644
--- a/web-ui/src/lib/platform.ts
+++ b/web-ui/src/lib/platform.ts
@@ -16,3 +16,17 @@ export function isLGWebOS(): boolean {
export function isDesktopDevice(): boolean {
return document.documentElement.dataset.playerPlatform === "desktop";
}
+
+/**
+ * Whether `HTMLMediaElement.volume` actually affects playback.
+ *
+ * iOS and iPadOS ignore volume writes because the level belongs to the hardware buttons;
+ * `muted` stays settable, so muting still works. Feature-detecting this does not work:
+ * assigning to a detached element's `volume` reads the value back unchanged, so a probe
+ * reports support that playback then does not honour. Hence the UA check, reusing the
+ * platform tag that `player.html` sets — it also covers iOS-wrapped browsers (CriOS,
+ * FxiOS, ...) and iPadOS reporting itself as MacIntel.
+ */
+export function isVolumeControlSupported(): boolean {
+ return !isIOS();
+}
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}