Files
FlightTube/src/components/PlayerControls.tsx
T
vincent 62110b28fc fix: subtitles no longer depend on the Settings preference
Verified in the running app: captions render on a downloaded video and
on a stream, and the player's menu lists them under SUBTITLES beside
AUDIO.

Three things were wrong.

The preference decided whether subtitles were fetched at all, so "Off"
— the old default, which nobody had to choose — silently emptied the
subtitle menu as well. Fetching now happens regardless; the preference
only says which language switches itself on.

Choosing a track from the menu moved the preference from "off" to that
language, which re-ran the fetch, tore down the tracks and stalled the
stream mid-play. The fetch keys on the language it would ask for, not
on the preference, and that value does not change when "off" becomes
the language it already stood for.

The preference was re-applied on every poll of the track list, so a
choice made in the menu was undone a second later. It now applies once
per set of tracks, which also makes "Off" mean off.

Anyone still carrying the old "off" default is moved to English once.
An Off chosen deliberately after this is left alone.
2026-08-29 16:46:34 +02:00

451 lines
17 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
/** WebKit exposes HLS alternate audio renditions here; the DOM lib omits it. */
interface AudioTrackLike {
id: string;
label: string;
language: string;
enabled: boolean;
}
interface AudioTrackListLike {
length: number;
[index: number]: AudioTrackLike;
addEventListener?: (t: string, fn: () => void) => void;
removeEventListener?: (t: string, fn: () => void) => void;
}
type VideoWithTracks = HTMLVideoElement & { audioTracks?: AudioTrackListLike };
function listAudio(v: HTMLVideoElement | null): AudioTrackLike[] {
const list = (v as VideoWithTracks | null)?.audioTracks;
if (!list) return [];
return Array.from({ length: list.length }, (_, i) => list[i]);
}
function listSubs(v: HTMLVideoElement | null): TextTrack[] {
if (!v) return [];
return Array.from(v.textTracks).filter(
(t) => t.kind === "subtitles" || t.kind === "captions",
);
}
interface Props {
videoRef: React.RefObject<HTMLVideoElement | null>;
/** Element to fullscreen — the stage, so letterboxing travels with it. */
stageRef: React.RefObject<HTMLDivElement | null>;
/** Nudges the auto-hide timer whenever the user does something. */
onActivity?: () => void;
/** Preferred subtitle language, or "off" to start with none. */
subLang: string;
/** Persists a choice made here, so the next video matches. */
onSubLang: (l: string) => void;
/** Captions are being fetched, so the menu is not empty for lack of any. */
subsLoading?: boolean;
}
const SKIP_S = 10;
function clock(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
return `${h > 0 ? `${h}:` : ""}${mm}:${String(s).padStart(2, "0")}`;
}
const btn =
"grid size-10 shrink-0 place-items-center rounded-lg text-white/90 cursor-pointer " +
"hover:bg-white/15 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed";
/**
* Our own transport bar.
*
* WebKit's native controls put fullscreen, Picture-in-Picture and volume as
* overlay buttons in the top corners of the video and give no way to move them.
* Owning the bar is the only way to get every control into one strip along the
* bottom, so the native ones are switched off entirely.
*/
export default function PlayerControls({
videoRef, stageRef, onActivity, subLang, onSubLang, subsLoading,
}: Props) {
const [playing, setPlaying] = useState(false);
const [time, setTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [pip, setPip] = useState(false);
const [full, setFull] = useState(false);
const [menu, setMenu] = useState(false);
const [audio, setAudio] = useState<AudioTrackLike[]>([]);
const [subs, setSubs] = useState<TextTrack[]>([]);
const [, bump] = useState(0);
const menuRef = useRef<HTMLDivElement>(null);
// How many text tracks the preference was last applied to.
const applied = useRef(-1);
// Tracks arrive with the manifest, after metadata rather than on mount.
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const read = () => {
setAudio(listAudio(v));
setSubs(listSubs(v));
};
read();
// Subtitles follow the language chosen in Settings and nothing else.
// WebKit will otherwise switch on whatever matches the system language,
// which is the same unwanted auto-selection as a dubbed audio track.
// Applied once per set of tracks. Re-applying on every poll would undo a
// choice made in the menu a second after it was made.
applied.current = -1;
const applyPreference = () => {
const tracks = Array.from(v.textTracks);
if (tracks.length === applied.current) return;
applied.current = tracks.length;
const wanted =
subLang === "off"
? undefined
: tracks.find((t) =>
(t.language || "").toLowerCase().startsWith(subLang.toLowerCase()),
);
for (const t of tracks) t.mode = t === wanted ? "showing" : "disabled";
read();
};
applyPreference();
v.addEventListener("loadedmetadata", applyPreference);
// HLS subtitle renditions arrive after metadata, so re-apply as they land.
v.textTracks.addEventListener?.("addtrack", applyPreference);
v.addEventListener("loadedmetadata", read);
const at = (v as VideoWithTracks).audioTracks;
at?.addEventListener?.("addtrack", read);
v.textTracks.addEventListener?.("addtrack", read);
// Manifests can take a moment to surface renditions.
const id = setInterval(read, 1000);
const stop = setTimeout(() => clearInterval(id), 8000);
return () => {
v.removeEventListener("loadedmetadata", applyPreference);
v.textTracks.removeEventListener?.("addtrack", applyPreference);
v.removeEventListener("loadedmetadata", read);
at?.removeEventListener?.("addtrack", read);
v.textTracks.removeEventListener?.("addtrack", read);
clearInterval(id);
clearTimeout(stop);
};
}, [videoRef, subLang]);
// Close the menu on any outside click.
useEffect(() => {
if (!menu) return;
const onDown = (e: MouseEvent) => {
if (!menuRef.current?.contains(e.target as Node)) setMenu(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [menu]);
const chooseAudio = (i: number) => {
const v = videoRef.current;
const list = (v as VideoWithTracks | null)?.audioTracks;
if (!list) return;
// Exactly one enabled, or WebKit mixes them.
for (let k = 0; k < list.length; k++) list[k].enabled = k === i;
bump((n) => n + 1);
onActivity?.();
};
const chooseSub = (track: TextTrack | null) => {
const v = videoRef.current;
if (!v) return;
for (const t of Array.from(v.textTracks)) {
t.mode = t === track ? "showing" : "disabled";
}
// The choice becomes the preference, so the next video matches without
// going back to Settings.
onSubLang(track ? (track.language || "off").split("-")[0] : "off");
bump((n) => n + 1);
onActivity?.();
};
// Mirror the element's state rather than assuming ours is authoritative —
// playback can change from the keyboard, the system, or the video ending.
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const sync = () => {
setPlaying(!v.paused);
setTime(v.currentTime);
setDuration(Number.isFinite(v.duration) ? v.duration : 0);
setVolume(v.volume);
setMuted(v.muted);
};
const onPip = () => setPip(!!document.pictureInPictureElement);
const onFull = () => setFull(!!document.fullscreenElement);
sync();
for (const e of ["play", "pause", "timeupdate", "durationchange", "volumechange", "loadedmetadata", "ended"]) {
v.addEventListener(e, sync);
}
v.addEventListener("enterpictureinpicture", onPip);
v.addEventListener("leavepictureinpicture", onPip);
document.addEventListener("fullscreenchange", onFull);
return () => {
for (const e of ["play", "pause", "timeupdate", "durationchange", "volumechange", "loadedmetadata", "ended"]) {
v.removeEventListener(e, sync);
}
v.removeEventListener("enterpictureinpicture", onPip);
v.removeEventListener("leavepictureinpicture", onPip);
document.removeEventListener("fullscreenchange", onFull);
};
}, [videoRef]);
const act = useCallback(
(fn: (v: HTMLVideoElement) => void) => () => {
const v = videoRef.current;
if (v) fn(v);
onActivity?.();
},
[videoRef, onActivity],
);
const togglePlay = act((v) => {
if (v.paused) void v.play().catch(() => {});
else v.pause();
});
const togglePip = useCallback(async () => {
const v = videoRef.current;
if (!v) return;
onActivity?.();
try {
if (document.pictureInPictureElement) await document.exitPictureInPicture();
else await v.requestPictureInPicture();
} catch {
/* not available for this source */
}
}, [videoRef, onActivity]);
const toggleFull = useCallback(async () => {
onActivity?.();
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await stageRef.current?.requestFullscreen();
} catch {
/* element fullscreen unavailable */
}
}, [stageRef, onActivity]);
const pct = duration > 0 ? (time / duration) * 100 : 0;
return (
<div
// Clicks here must not reach the video's own play/pause handler.
onClick={(e) => e.stopPropagation()}
className="absolute inset-x-0 bottom-0 z-20 flex items-center gap-2.5 bg-gradient-to-t
from-slate-950/90 via-slate-950/65 to-transparent px-5 pb-4 pt-10"
>
<button onClick={togglePlay} className={btn} title={playing ? "Pause (space)" : "Play (space)"}>
{playing ? (
<svg viewBox="0 0 24 24" className="size-5" fill="currentColor">
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
) : (
<svg viewBox="0 0 24 24" className="size-5" fill="currentColor">
<path d="M8 5.5v13l11-6.5z" />
</svg>
)}
</button>
<button
onClick={act((v) => (v.currentTime = Math.max(0, v.currentTime - SKIP_S)))}
className={btn}
title={`Back ${SKIP_S}s`}
>
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M11 17l-5-5 5-5M18 17l-5-5 5-5" />
</svg>
</button>
<button
onClick={act((v) => (v.currentTime = Math.min(v.duration || 0, v.currentTime + SKIP_S)))}
className={btn}
title={`Forward ${SKIP_S}s`}
>
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5-5 5M6 7l5 5-5 5" />
</svg>
</button>
<span className="shrink-0 font-mono text-[12px] tabular-nums text-white/85">{clock(time)}</span>
<input
type="range"
min={0}
max={duration || 0}
step={0.1}
value={time}
onChange={(e) => {
const v = videoRef.current;
if (v) v.currentTime = Number(e.target.value);
onActivity?.();
}}
aria-label="Seek"
className="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-white/25 accent-sky-500"
style={{
background: `linear-gradient(to right, var(--color-sky-500) ${pct}%, rgba(255,255,255,0.25) ${pct}%)`,
}}
/>
<span className="shrink-0 font-mono text-[12px] tabular-nums text-white/85">{clock(duration)}</span>
<button
onClick={act((v) => (v.muted = !v.muted))}
className={btn}
title={muted || volume === 0 ? "Unmute" : "Mute"}
>
<svg viewBox="0 0 24 24" className="size-5" fill="currentColor">
<path d="M4 9v6h4l5 4V5L8 9H4z" />
{muted || volume === 0 ? (
<path d="M16 9l5 6M21 9l-5 6" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" />
) : (
<path
d="M16.5 8.5a5 5 0 010 7M19 6a8.5 8.5 0 010 12"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
/>
)}
</svg>
</button>
<input
type="range"
min={0}
max={1}
step={0.02}
value={muted ? 0 : volume}
onChange={(e) => {
const v = videoRef.current;
if (!v) return;
v.volume = Number(e.target.value);
v.muted = Number(e.target.value) === 0;
onActivity?.();
}}
aria-label="Volume"
className="h-1.5 w-24 shrink-0 cursor-pointer appearance-none rounded-full accent-sky-500"
style={{
background: `linear-gradient(to right, rgba(255,255,255,0.85) ${
(muted ? 0 : volume) * 100
}%, rgba(255,255,255,0.25) ${(muted ? 0 : volume) * 100}%)`,
}}
/>
{(audio.length > 1 || subs.length > 0 || subsLoading) && (
<div ref={menuRef} className="relative shrink-0">
<button
onClick={() => { setMenu((m) => !m); onActivity?.(); }}
className={btn}
title="Audio and subtitles"
>
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="5" width="18" height="14" rx="2" />
<path strokeLinecap="round" d="M7 15h4M14 15h3" />
</svg>
</button>
{menu && (
<div
className="absolute bottom-full right-0 mb-2 max-h-72 w-60 overflow-y-auto rounded-lg
border border-slate-700 bg-slate-900/95 p-1 shadow-2xl backdrop-blur"
>
{audio.length > 1 && (
<>
<div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
Audio
</div>
{audio.map((t, i) => (
<button
key={t.id || `${t.language}-${i}`}
onClick={() => chooseAudio(i)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left
text-[12px] cursor-pointer hover:bg-slate-800 ${
t.enabled ? "text-white" : "text-slate-300"
}`}
>
<span className="w-3 shrink-0 text-sky-400">{t.enabled ? "✓" : ""}</span>
<span className="truncate">{t.label || t.language || `Track ${i + 1}`}</span>
</button>
))}
</>
)}
{subs.length === 0 && subsLoading && (
<>
<div className="mt-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
Subtitles
</div>
<div className="px-2 py-1.5 text-[12px] text-slate-400">Fetching</div>
</>
)}
{subs.length > 0 && (
<>
<div className="mt-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
Subtitles
</div>
<button
onClick={() => chooseSub(null)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left
text-[12px] cursor-pointer hover:bg-slate-800 ${
subs.every((t) => t.mode !== "showing")
? "text-white"
: "text-slate-300"
}`}
>
<span className="w-3 shrink-0 text-sky-400">
{subs.every((t) => t.mode !== "showing") ? "✓" : ""}
</span>
Off
</button>
{subs.map((t, i) => (
<button
key={t.id || `${t.language}-${i}`}
onClick={() => chooseSub(t)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left
text-[12px] cursor-pointer hover:bg-slate-800 ${
t.mode === "showing" ? "text-white" : "text-slate-300"
}`}
>
<span className="w-3 shrink-0 text-sky-400">
{t.mode === "showing" ? "✓" : ""}
</span>
<span className="truncate">{t.label || t.language || `Subtitles ${i + 1}`}</span>
</button>
))}
</>
)}
</div>
)}
</div>
)}
<button onClick={togglePip} className={btn} title={pip ? "Leave Picture in Picture" : "Picture in Picture"}>
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="5" width="18" height="14" rx="2" />
<rect x="12" y="11" width="7" height="6" rx="1" fill="currentColor" stroke="none" />
</svg>
</button>
<button onClick={toggleFull} className={btn} title={full ? "Leave full screen (f)" : "Full screen (f)"}>
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
{full ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
)}
</svg>
</button>
</div>
);
}