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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user