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. + + ); +} 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
+ {/* + 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..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}