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.
426 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|