feat: custom transport bar, live refresh, collapsible sidebar

Replace WebKit's native video controls with our own. WebKit puts
fullscreen, Picture-in-Picture and volume as overlay buttons in the
video's top corners with no way to move them; owning the bar is the only
way to get every control into one strip along the bottom. Right-click on
the player is suppressed — its menu acted on a video the app does not
control.

One spinner now covers both waits: resolving the stream URL and
buffering it. It no longer sticks after a resume-seek, which fires
'waiting' after 'playing'.

Also: Open on YouTube and a new Download are real buttons in the player;
refresh is a compact icon; Settings is a cog; the subscription sidebar
collapses and reappears on a left-edge hover; the feed auto-refreshes
every 10 minutes while online, skipped while the player is open; the
title-bar strip takes the panel colour, since it sits above panels
rather than the page; and downloaded-only, hide-Shorts and the sidebar
state are remembered between launches.
This commit is contained in:
vincent
2026-08-29 11:03:29 +02:00
parent 61db10b2c8
commit 211823f265
6 changed files with 448 additions and 56 deletions
+233
View File
@@ -0,0 +1,233 @@
import { useCallback, useEffect, useState } from "react";
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;
}
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-8 shrink-0 place-items-center rounded-md 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 }: 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);
// 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 bg-gradient-to-t
from-slate-950/85 via-slate-950/60 to-transparent px-4 pb-3 pt-8"
>
<button onClick={togglePlay} className={btn} title={playing ? "Pause (space)" : "Play (space)"}>
{playing ? (
<svg viewBox="0 0 24 24" className="size-4" 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-4" 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-4" 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-4" 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-[11px] tabular-nums text-white/80">{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 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-[11px] tabular-nums text-white/80">{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-4" 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 w-20 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}%)`,
}}
/>
<button onClick={togglePip} className={btn} title={pip ? "Leave Picture in Picture" : "Picture in Picture"}>
<svg viewBox="0 0 24 24" className="size-4" 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-4" 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>
);
}