Files
FlightTube/src/components/PlayerControls.tsx
T
vincent 557467baad feat: subtitles, delete-all, auto refresh, and menu cleanup
Subtitles: a language preference in Settings drives what is shown and
what is downloaded. yt-dlp saves WebVTT sidecars next to each download,
including YouTube's auto-generated track, and the player attaches them
as <track> elements so they work offline. Sidecars are removed with
their video, and by Delete all.

WebVTT rather than muxed subtitle streams because WebKit reads a <track>
reliably and largely ignores subtitle tracks inside an MP4.

Downloads in progress now appear under the downloaded-only filter, so a
download you just started does not vanish from the list you are watching
it in. That view also gains a Delete all, behind a confirmation naming
what goes.

The feed refreshes on launch and whenever the player closes, so the
Refresh button is only for staleness.

Player: Delete moved to the footer and shortened, Open on YouTube is now
an external-link icon.

Removes the Edit and Help menus. Cut/Copy/Paste move to the app menu,
without which their shortcuts would stop working in the search field.
The webview context menu is suppressed outside text fields — its Reload
and Back items act on a page the app does not present as one.

Settings now warns that 4K AV1 plays back with artefacts: the files
decode cleanly in ffmpeg, so it is the built-in decoder, not the
download.
2026-08-29 13:25:17 +02:00

426 lines
16 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;
}
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 }: 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);
// 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.
const applyPreference = () => {
const tracks = Array.from(v.textTracks);
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";
}
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) && (
<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 && (
<>
<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>
);
}