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. */}