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
+58 -26
View File
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { fileUrl, openExternal, resolveStream, savePlayback } from "../api";
import type { FeedItem } from "../types";
import { compactViews, relativeTime } from "./format";
import { BTN, BTN_CHROME, BTN_QUIET, Spinner } from "./ui";
import PlayerControls from "./PlayerControls";
import { BTN, BTN_CHROME, Spinner } from "./ui";
interface Props {
item: FeedItem;
@@ -12,6 +13,9 @@ interface Props {
onDelete: () => void;
onPrev?: () => void;
onNext?: () => void;
/** Present only while streaming, so the video can be saved from here. */
onDownload?: () => void;
downloading?: boolean;
/** Position in the current feed, for the "3 of 180" readout. */
index: number;
total: number;
@@ -88,12 +92,12 @@ const RESUME_EDGE_S = 5;
* cannot be used: it rejects a `tauri://` origin with "Error 153".
*/
export default function Player({
item, path, onClose, onDelete, onPrev, onNext, index, total,
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading, index, total,
}: Props) {
const streaming = path === null;
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
const [error, setError] = useState<string | null>(null);
const [buffering, setBuffering] = useState(false);
const [buffering, setBuffering] = useState(true);
const videoRef = useRef<HTMLVideoElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const lastSave = useRef(0);
@@ -133,6 +137,12 @@ export default function Player({
}, [src]);
const onTimeUpdate = () => {
// Frames are flowing, so whatever the media events claimed, we are not
// buffering. A resume-seek can fire `waiting` after `playing` and leave the
// spinner stuck otherwise.
const v = videoRef.current;
if (v && v.readyState >= 3 && !v.paused) setBuffering(false);
const now = Date.now();
if (now - lastSave.current < SAVE_EVERY_MS) return;
lastSave.current = now;
@@ -152,7 +162,9 @@ export default function Player({
// Escape backs out, as it does everywhere else in the app.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") leave();
// In fullscreen the browser already handles Escape; closing the player
// as well would drop you all the way back to the feed.
if (e.key === "Escape" && !document.fullscreenElement) leave();
// Arrow keys only when the video does not own them for seeking.
if (e.key === "ArrowLeft" && e.shiftKey) onPrev?.();
if (e.key === "ArrowRight" && e.shiftKey) onNext?.();
@@ -214,7 +226,11 @@ export default function Player({
{/* Absolute fill + object-contain, so portrait Shorts and landscape
videos are both letterboxed to the pane instead of overflowing it. */}
<div ref={stageRef} className="group/stage relative min-h-0 flex-1 bg-slate-950">
<div
ref={stageRef}
onContextMenu={(e) => e.preventDefault()}
className="group/stage relative min-h-0 flex-1 bg-slate-950"
>
{/* Edge arrows, the way a player wants them: big targets on the left and
right of the picture. They fade in on hover so they never sit on top
of the video while you are watching it. */}
@@ -237,10 +253,10 @@ export default function Player({
</button>
{/* Two different waits look the same to you: resolving the stream, and
the player buffering it. Both get the spinner. */}
{src && buffering && (
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
{/* Resolving the stream and buffering it are the same wait as far as
you are concerned, so they get the same spinner in the same place. */}
{(!src || buffering) && !error && (
<div className="pointer-events-none absolute inset-0 z-30 grid place-items-center">
<Spinner className="size-8 text-white/80" />
</div>
)}
@@ -250,8 +266,14 @@ export default function Player({
ref={videoRef}
key={src}
src={src}
controls
autoPlay
onContextMenu={(e) => e.preventDefault()}
onClick={() => {
const v = videoRef.current;
if (!v) return;
if (v.paused) void v.play().catch(() => {});
else v.pause();
}}
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMetadata}
onPause={persist}
@@ -260,27 +282,26 @@ export default function Player({
onStalled={() => setBuffering(true)}
onCanPlay={() => setBuffering(false)}
onPlaying={() => setBuffering(false)}
onSeeked={() => setBuffering(false)}
className="absolute inset-0 size-full object-contain"
/>
) : (
<div className="absolute inset-0 grid place-items-center px-6 text-center">
{error ? (
error && (
<div className="absolute inset-0 grid place-items-center px-6 text-center">
<div className="max-w-sm">
<p className="text-[13px] text-red-400">{error}</p>
<button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
className={`${BTN_QUIET} mt-2 cursor-pointer`}
className={`${BTN} mt-3 cursor-pointer py-1.5`}
>
Open on YouTube instead
</button>
</div>
) : (
<div className="flex items-center gap-2 text-slate-400">
<Spinner />
<span className="text-[12px]">Finding a stream</span>
</div>
)}
</div>
</div>
)
)}
{src && !error && (
<PlayerControls videoRef={videoRef} stageRef={stageRef} />
)}
</div>
@@ -297,12 +318,23 @@ export default function Player({
.join(" · ")}
</div>
</div>
<button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
className={`${BTN_QUIET} shrink-0 cursor-pointer whitespace-nowrap`}
>
Open on YouTube
</button>
<div className="flex shrink-0 items-center gap-2">
{onDownload && (
<button
onClick={onDownload}
disabled={downloading}
className={`${BTN} cursor-pointer whitespace-nowrap py-1.5`}
>
{downloading ? "Downloading…" : "Download"}
</button>
)}
<button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
className={`${BTN} cursor-pointer whitespace-nowrap py-1.5`}
>
Open on YouTube
</button>
</div>
</div>
{/* Collapsed by default — the description is rarely what you came for. */}
+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>
);
}
+35 -6
View File
@@ -8,10 +8,14 @@ interface Props {
onOpenSettings: () => void;
totalVideos: number;
totalDownloaded: number;
onHide: () => void;
/** True when revealed by hover over the left edge rather than pinned open. */
floating?: boolean;
}
export default function Sidebar({
channels, activeChannel, onSelect, onOpenSettings, totalVideos, totalDownloaded,
onHide, floating,
}: Props) {
const row =
"flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 " +
@@ -22,9 +26,14 @@ export default function Sidebar({
return (
<aside
className="flex max-h-[45vh] w-full shrink-0 flex-col overflow-hidden border-b
border-slate-300 bg-white lg:h-full lg:max-h-none lg:w-[280px]
lg:border-b-0 lg:border-r dark:border-slate-800 dark:bg-slate-900"
className={
"flex flex-col overflow-hidden border-slate-300 bg-white dark:border-slate-800 " +
"dark:bg-slate-900 " +
(floating
? "absolute inset-y-0 left-0 z-30 w-[280px] border-r shadow-2xl"
: "max-h-[45vh] w-full shrink-0 border-b lg:h-full lg:max-h-none lg:w-[280px] " +
"lg:border-b-0 lg:border-r")
}
>
<header
className="sticky top-0 z-20 flex items-center justify-between gap-2 border-b
@@ -35,9 +44,29 @@ export default function Sidebar({
<span aria-hidden className="text-sky-500"></span>
FlightTube
</span>
<button onClick={onOpenSettings} className={`${BTN_CHROME} cursor-pointer`}>
Settings
</button>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={onOpenSettings}
title="Settings"
aria-label="Settings"
className={`${BTN_CHROME} grid size-7 cursor-pointer place-items-center`}
>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="12" cy="12" r="3.2" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.14.34.4.62.73.79.24.13.51.2.78.21H21a2 2 0 110 4h-.09c-.7.01-1.33.43-1.51 1z" />
</svg>
</button>
<button
onClick={onHide}
title="Hide subscriptions"
aria-label="Hide subscriptions"
className={`${BTN_CHROME} grid size-7 cursor-pointer place-items-center`}
>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" />
</svg>
</button>
</div>
</header>
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
+35 -8
View File
@@ -1,4 +1,4 @@
import { BTN_PRIMARY, INPUT, Segmented } from "./ui";
import { INPUT, Segmented } from "./ui";
export type ViewMode = "list" | "grid";
@@ -19,6 +19,8 @@ interface Props {
resultCount: number;
view: ViewMode;
onView: (v: ViewMode) => void;
sidebarHidden: boolean;
onShowSidebar: () => void;
}
/** Neutral outline until active; active is the one filled state. */
@@ -54,6 +56,7 @@ export default function TopBar({
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
online, reachable, forcedOffline, onToggleForcedOffline,
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
sidebarHidden, onShowSidebar,
}: Props) {
const pct = refreshProgress && refreshProgress.total > 0
? (refreshProgress.done / refreshProgress.total) * 100
@@ -65,6 +68,21 @@ export default function TopBar({
dark:border-slate-800 dark:bg-slate-900/95"
>
<div className="flex flex-wrap items-center gap-2 px-4 py-3">
{sidebarHidden && (
<button
onClick={onShowSidebar}
title="Show subscriptions"
aria-label="Show subscriptions"
className="grid size-[30px] shrink-0 cursor-pointer place-items-center rounded-lg
border border-slate-300 text-slate-500 hover:border-sky-500
hover:text-sky-600 dark:border-slate-700 dark:text-slate-400
dark:hover:border-sky-500 dark:hover:text-sky-400"
>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
)}
<input
value={search}
onChange={(e) => onSearch(e.target.value)}
@@ -120,13 +138,22 @@ export default function TopBar({
</button>
<button onClick={onRefresh} disabled={refreshing || !online}
title={online ? "Fetch the latest videos from every channel" : "Refreshing needs a connection"}
className={`${BTN_PRIMARY} cursor-pointer py-1.5 font-mono text-[11px] tabular-nums`}>
{refreshing
? refreshProgress
? `${refreshProgress.done}/${refreshProgress.total}`
: "Refreshing"
: "Refresh"}
title={
online
? refreshProgress
? `Refreshing ${refreshProgress.done}/${refreshProgress.total}`
: "Fetch the latest videos from every channel"
: "Refreshing needs a connection"
}
aria-label="Refresh"
className="grid size-[30px] shrink-0 cursor-pointer place-items-center rounded-lg
bg-sky-500 text-white hover:bg-sky-400 disabled:cursor-not-allowed
disabled:opacity-40">
<svg viewBox="0 0 24 24" className={`size-4 ${refreshing ? "animate-spin" : ""}`}
fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round"
d="M20 11a8 8 0 10-2.3 5.7M20 5v6h-6" />
</svg>
</button>
</div>