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:
+58
-26
@@ -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. */}
|
||||
|
||||
Reference in New Issue
Block a user