Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 27 additions & 21 deletions web-ui/src/components/player/player-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -391,6 +393,7 @@ function PlayerControlsComponent({
onPlayPause,
volume,
onVolumeChange,
canControlVolume,
isMuted,
onMuteToggle,
onFullscreen,
Expand Down Expand Up @@ -470,27 +473,30 @@ function PlayerControlsComponent({
)}
</button>

{/* Volume Slider */}
<div
className={clsx(
PLAYER_OVERLAY_SURFACE_CLASS,
"player-performance-motion invisible absolute bottom-full left-1/2 flex -translate-x-1/2 cursor-pointer items-center justify-center rounded-xl px-2 py-2 opacity-0 transition-[opacity,visibility] duration-150 group-hover/volume:visible group-hover/volume:opacity-100 group-focus-within/volume:visible group-focus-within/volume:opacity-100 md:px-3",
)}
>
<PlayerSelectedGlassLayers compact />
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={(e) => 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%)`,
}}
/>
</div>
{/* Volume Slider — omitted where the platform ignores volume writes (iOS),
since it would move without changing anything. Mute still works there. */}
{canControlVolume && (
<div
className={clsx(
PLAYER_OVERLAY_SURFACE_CLASS,
"player-performance-motion invisible absolute bottom-full left-1/2 flex -translate-x-1/2 cursor-pointer items-center justify-center rounded-xl px-2 py-2 opacity-0 transition-[opacity,visibility] duration-150 group-hover/volume:visible group-hover/volume:opacity-100 group-focus-within/volume:visible group-focus-within/volume:opacity-100 md:px-3",
)}
>
<PlayerSelectedGlassLayers compact />
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={(e) => 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%)`,
}}
/>
</div>
)}
</div>

<PlayerTimeDisplay currentProgram={currentProgram} seekStartTime={seekStartTime} />
Expand Down
131 changes: 131 additions & 0 deletions web-ui/src/components/player/player-gesture-overlay.tsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<VolumeX className={ICON_CLASS} />
) : volume < 0.5 ? (
<Volume1 className={ICON_CLASS} />
) : (
<Volume2 className={ICON_CLASS} />
)}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-blue-50/15 shadow-[inset_0_1px_3px_rgba(0,0,0,0.45)] ring-1 ring-white/10 md:w-40">
<div
className="player-performance-progress-fill h-full rounded-full bg-[linear-gradient(90deg,#3b82f6_0%,#38bdf8_52%,#6366f1_100%)] shadow-[0_0_18px_rgba(59,130,246,0.4)]"
style={{ width: `${percent}%` }}
/>
</div>
<span className="w-10 shrink-0 text-right font-semibold text-blue-50 text-sm tabular-nums md:text-base">
{percent}%
</span>
</>
);
}

function ChannelIndicator({
indicator,
label,
}: {
indicator: Extract<PlayerGestureIndicator, { kind: "channel" }>;
/** 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 (
<>
<Chevron className={ICON_CLASS} />
{target ? (
<>
<span className="shrink-0 rounded-md bg-blue-100/10 px-1.5 py-0.5 font-semibold text-blue-50/65 text-xs ring-1 ring-blue-100/10 md:text-sm">
{target.id}
</span>
<span className="max-w-[40vw] truncate font-bold text-sm text-white md:text-lg">{target.name}</span>
</>
) : (
<span className="font-bold text-sm text-white md:text-lg">{label}</span>
)}
</>
);
}

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.
<div aria-hidden="true" className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center p-4">
<div
className={clsx(
PLAYER_OVERLAY_SURFACE_CLASS,
"player-performance-motion relative flex max-w-full items-center gap-2 rounded-xl px-3 py-2 transition-opacity duration-200 md:gap-3 md:px-4 md:py-3 [@container_video_(max-height:_320px)]:gap-1.5 [@container_video_(max-height:_320px)]:rounded-lg [@container_video_(max-height:_320px)]:px-2 [@container_video_(max-height:_320px)]:py-1.5",
indicator ? "opacity-100" : "opacity-0",
)}
>
<PlayerSelectedGlassLayers />
<div className="relative z-10 flex min-w-0 items-center gap-2 md:gap-3">
{shown?.kind === "volume" && <VolumeIndicator volume={shown.volume} />}
{shown?.kind === "channel" && (
<ChannelIndicator
indicator={shown}
label={shown.direction === "prev" ? t("previousChannel") : t("nextChannel")}
/>
)}
{shown?.kind === "seek" && (
<>
{shown.deltaSeconds < 0 ? <Rewind className={ICON_CLASS} /> : <FastForward className={ICON_CLASS} />}
<span className="font-bold text-base text-white tabular-nums md:text-xl">
{formatSeekDelta(shown.deltaSeconds)}
</span>
</>
)}
</div>
</div>
</div>
);
}
102 changes: 81 additions & 21 deletions web-ui/src/components/player/video-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -262,6 +268,8 @@ function VideoPlayerComponent({
streamStartTime,
onCurrentVideoTimeChange,
onChannelNavigate,
prevChannel = null,
nextChannel = null,
showSidebar = true,
onToggleSidebar,
isFullscreen,
Expand All @@ -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<HTMLDivElement>(null);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -526,24 +539,7 @@ function VideoPlayerComponent({
[hideControlsImmediately],
);

// Click / tap toggles controls. The handler lives on the whole player surface (not
// just the <video>) 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(() => {
Expand Down Expand Up @@ -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 <video>) 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<boolean> => {
const documentPictureInPicture = getDocumentPictureInPicture();
const pipWindow = documentPictureInPicture?.window ?? documentPiPWindowRef.current;
Expand Down Expand Up @@ -1773,6 +1812,22 @@ function VideoPlayerComponent({
))}
</div>

{/*
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 && (
<div
aria-hidden="true"
data-player-surface-hit=""
className="absolute inset-0 z-[1] touch-none select-none"
{...gestureHandlers}
/>
)}

{!needsUserInteraction && !error && (
<PlayerTopLeftOverlay
visible={showControls || showLoading}
Expand Down Expand Up @@ -1972,6 +2027,7 @@ function VideoPlayerComponent({
onPlayPause={togglePlayPause}
volume={volume}
onVolumeChange={handleVolumeChange}
canControlVolume={canControlVolume}
isMuted={isMuted}
onMuteToggle={handleMuteToggle}
onFullscreen={handleFullscreen}
Expand All @@ -1987,6 +2043,10 @@ function VideoPlayerComponent({
/>
</div>
)}

{channel && !error && !needsUserInteraction && (
<PlayerGestureIndicatorOverlay indicator={gestureIndicator} locale={locale} />
)}
</div>
);

Expand Down
Loading
Loading