Subtitles rendered pinned to the left edge and clipped, in half-grey karaoke text: YouTube's auto-captions carry align:start position:0% on every cue plus inline <00:00:12.480><c>word</c> timing tags. Both are now stripped after download, and existing downloads are tidied the first time they are listed. Thumbnails show video length. The Atom feed carries no duration, so it is read from the watch page and cached — but only a trickle. The first version fetched 24 pages every 12 seconds at six concurrent, roughly two requests a second sustained, and YouTube answered by challenging the whole IP: 'Sign in to confirm you are not a bot', which broke streaming and downloads too. It is now four pages every five minutes, one at a time, and stops for the session the moment a batch is refused. A subtitle chosen in the player becomes the stored preference, so the next video matches without going back to Settings. The back and download buttons are square icon buttons; the back glyph was off-centre because px-2 beat the p-0 meant to clear it.
451 lines
17 KiB
TypeScript
451 lines
17 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { fileUrl, listSubtitles, openExternal, resolveStream, savePlayback } from "../api";
|
||
import type { FeedItem } from "../types";
|
||
import { compactViews, relativeTime } from "./format";
|
||
import PlayerControls from "./PlayerControls";
|
||
import { BTN, Spinner } from "./ui";
|
||
|
||
interface Props {
|
||
item: FeedItem;
|
||
/** Local file when downloaded; null means resolve and stream it instead. */
|
||
path: string | null;
|
||
onClose: () => void;
|
||
onDelete: () => void;
|
||
onPrev?: () => void;
|
||
onNext?: () => void;
|
||
/** Present only while streaming, so the video can be saved from here. */
|
||
onDownload?: () => void;
|
||
downloading?: boolean;
|
||
/** Max height for streaming, or null to let the player adapt. */
|
||
maxHeight: number | null;
|
||
/** Preferred subtitle language, or "off". */
|
||
subLang: string;
|
||
/** Persists a subtitle choice made from the transport bar. */
|
||
onSubLang: (l: string) => void;
|
||
/** Position in the current feed, for the "3 of 180" readout. */
|
||
index: number;
|
||
total: number;
|
||
/** False in window fullscreen, where there are no traffic lights to clear. */
|
||
titleBarInset: boolean;
|
||
}
|
||
|
||
/**
|
||
* Releases a <video> completely.
|
||
*
|
||
* Detaching the element is not enough: WebKit keeps a Picture-in-Picture
|
||
* session (and its audio) running after the element leaves the DOM, so closing
|
||
* the player or stepping to the next video would leave the previous one playing
|
||
* with no way to stop it. Every exit path goes through here.
|
||
*/
|
||
function teardown(v: HTMLVideoElement | null) {
|
||
if (!v) return;
|
||
// Safari's PiP is the non-standard presentation-mode API; the spec one is
|
||
// tried too, since either may be the live implementation.
|
||
const webkit = v as WebkitVideo;
|
||
try {
|
||
if (webkit.webkitPresentationMode && webkit.webkitPresentationMode !== "inline") {
|
||
webkit.webkitSetPresentationMode?.("inline");
|
||
}
|
||
} catch {
|
||
/* not supported here */
|
||
}
|
||
try {
|
||
if (webkit.webkitDisplayingFullscreen) webkit.webkitExitFullscreen?.();
|
||
} catch {
|
||
/* not supported here */
|
||
}
|
||
try {
|
||
if (document.pictureInPictureElement) void document.exitPictureInPicture();
|
||
} catch {
|
||
/* not supported here */
|
||
}
|
||
try {
|
||
if (document.fullscreenElement) void document.exitFullscreen();
|
||
} catch {
|
||
/* not supported here */
|
||
}
|
||
try {
|
||
v.pause();
|
||
// Dropping the source is what actually frees the decoder and the audio.
|
||
v.removeAttribute("src");
|
||
v.load();
|
||
} catch {
|
||
/* already gone */
|
||
}
|
||
}
|
||
|
||
/** The WebKit-only members we rely on for PiP and native video fullscreen. */
|
||
type WebkitVideo = HTMLVideoElement & {
|
||
webkitPresentationMode?: string;
|
||
webkitSetPresentationMode?: (mode: string) => void;
|
||
webkitEnterFullscreen?: () => void;
|
||
webkitExitFullscreen?: () => void;
|
||
webkitDisplayingFullscreen?: boolean;
|
||
};
|
||
|
||
/** Save at most this often while playing; also saved on close. */
|
||
const SAVE_EVERY_MS = 5000;
|
||
/** Ignore a saved position this close to either end — nothing useful to resume. */
|
||
const RESUME_EDGE_S = 5;
|
||
|
||
/**
|
||
* Two sources, one player element.
|
||
*
|
||
* Downloaded: the local file through Tauri's asset protocol. Every download is
|
||
* H.264/AAC in MP4 precisely so WKWebView can decode it natively.
|
||
*
|
||
* Not downloaded: YouTube's HLS master playlist, resolved by yt-dlp. Its
|
||
* variants are H.264 + AAC up to 1080p, which AVFoundation streams natively —
|
||
* so watching still happens here rather than in a browser. The iframe embed
|
||
* cannot be used: it rejects a `tauri://` origin with "Error 153".
|
||
*/
|
||
export default function Player({
|
||
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
|
||
maxHeight, subLang, onSubLang, index, total, titleBarInset,
|
||
}: 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(true);
|
||
// WebVTT files yt-dlp saved next to a download, so subtitles work offline.
|
||
const [sidecars, setSidecars] = useState<Array<[string, string]>>([]);
|
||
|
||
useEffect(() => {
|
||
if (!path) {
|
||
setSidecars([]);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
listSubtitles(item.id)
|
||
.then((s) => !cancelled && setSidecars(s))
|
||
.catch(() => !cancelled && setSidecars([]));
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [item.id, path]);
|
||
// Controls and edge arrows fade away while you are just watching.
|
||
const [chromeVisible, setChromeVisible] = useState(true);
|
||
const hideTimer = useRef<number | undefined>(undefined);
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
const stageRef = useRef<HTMLDivElement>(null);
|
||
const lastSave = useRef(0);
|
||
|
||
useEffect(() => {
|
||
if (path) {
|
||
setSrc(fileUrl(path));
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
setSrc(null);
|
||
setError(null);
|
||
resolveStream(item.id, maxHeight)
|
||
.then((u) => !cancelled && setSrc(u))
|
||
.catch((e) => !cancelled && setError(String(e)));
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [item.id, path, maxHeight]);
|
||
|
||
|
||
const persist = useCallback(() => {
|
||
const v = videoRef.current;
|
||
if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return;
|
||
savePlayback(item.id, v.currentTime, v.duration).catch(() => {
|
||
/* a lost position is not worth interrupting playback for */
|
||
});
|
||
}, [item.id]);
|
||
|
||
// Save on the way out, including when switching to another video. Declared
|
||
// before the teardown effect so React runs this cleanup first — the position
|
||
// has to be read off the element before its source is dropped.
|
||
useEffect(() => () => persist(), [persist]);
|
||
|
||
useEffect(() => {
|
||
const v = videoRef.current;
|
||
return () => teardown(v);
|
||
}, [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;
|
||
persist();
|
||
};
|
||
|
||
// Pick up where this video was left off, unless that was right at either end.
|
||
const onLoadedMetadata = () => {
|
||
const v = videoRef.current;
|
||
const at = item.position ?? 0;
|
||
if (!v || !Number.isFinite(v.duration)) return;
|
||
if (at > RESUME_EDGE_S && at < v.duration - RESUME_EDGE_S) v.currentTime = at;
|
||
};
|
||
|
||
const leave = useCallback(() => onClose(), [onClose]);
|
||
|
||
// Escape backs out, as it does everywhere else in the app.
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
// 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?.();
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, [leave, onPrev, onNext]);
|
||
|
||
const edgeBtn =
|
||
"absolute top-1/2 z-10 -translate-y-1/2 grid size-11 place-items-center rounded-full " +
|
||
"bg-slate-950/55 text-2xl leading-none text-white backdrop-blur cursor-pointer " +
|
||
"transition-opacity duration-200 hover:bg-slate-950/80 disabled:hidden " +
|
||
(chromeVisible ? "opacity-100" : "pointer-events-none opacity-0");
|
||
|
||
const showChrome = useCallback(() => {
|
||
setChromeVisible(true);
|
||
window.clearTimeout(hideTimer.current);
|
||
hideTimer.current = window.setTimeout(() => {
|
||
// Never hide while paused — there would be no way back to play.
|
||
if (!videoRef.current?.paused) setChromeVisible(false);
|
||
}, 2600);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
showChrome();
|
||
return () => window.clearTimeout(hideTimer.current);
|
||
}, [showChrome, src]);
|
||
|
||
const navIcon =
|
||
"grid h-[30px] w-[30px] shrink-0 place-items-center rounded-lg border border-slate-300 " +
|
||
"cursor-pointer hover:border-sky-500 hover:text-sky-600 disabled:opacity-30 " +
|
||
"disabled:cursor-not-allowed dark:border-slate-700 dark:hover:border-sky-500 " +
|
||
"dark:hover:text-sky-400";
|
||
|
||
const navBtn =
|
||
"inline-flex h-[30px] items-center rounded-lg border border-slate-300 px-2.5 text-[12px] font-medium cursor-pointer " +
|
||
"hover:border-sky-500 hover:text-sky-600 disabled:opacity-30 disabled:cursor-not-allowed " +
|
||
"disabled:hover:border-slate-300 disabled:hover:text-inherit " +
|
||
"dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400";
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950">
|
||
{titleBarInset && <div data-tauri-drag-region className="h-9 shrink-0" />}
|
||
<header
|
||
className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3
|
||
dark:border-slate-800 dark:bg-slate-900"
|
||
>
|
||
<button onClick={leave} title="Back to the feed (Esc)" aria-label="Back"
|
||
className={navIcon}>
|
||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" />
|
||
</svg>
|
||
</button>
|
||
|
||
<button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}>
|
||
‹ Prev
|
||
</button>
|
||
<button onClick={onNext} disabled={!onNext} title="Next video" className={navBtn}>
|
||
Next ›
|
||
</button>
|
||
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
|
||
{index + 1}/{total}
|
||
</span>
|
||
|
||
<span className="min-w-0 flex-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
|
||
{item.channel_title}
|
||
</span>
|
||
|
||
{streaming && (
|
||
<span className="text-[11px] text-slate-400 dark:text-slate-500">
|
||
{error ? "Unavailable" : src && !buffering ? "Streaming" : "Loading…"}
|
||
</span>
|
||
)}
|
||
</header>
|
||
|
||
{/* Absolute fill + object-contain, so portrait Shorts and landscape
|
||
videos are both letterboxed to the pane instead of overflowing it. */}
|
||
<div
|
||
ref={stageRef}
|
||
onContextMenu={(e) => e.preventDefault()}
|
||
onMouseMove={showChrome}
|
||
onMouseLeave={() => !videoRef.current?.paused && setChromeVisible(false)}
|
||
className={`relative min-h-0 flex-1 bg-slate-950 ${chromeVisible ? "" : "cursor-none"}`}
|
||
>
|
||
{/* 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. */}
|
||
<button
|
||
onClick={onPrev}
|
||
disabled={!onPrev}
|
||
title="Previous video"
|
||
aria-label="Previous video"
|
||
className={`${edgeBtn} left-3`}
|
||
>
|
||
‹
|
||
</button>
|
||
<button
|
||
onClick={onNext}
|
||
disabled={!onNext}
|
||
title="Next video"
|
||
aria-label="Next video"
|
||
className={`${edgeBtn} right-3`}
|
||
>
|
||
›
|
||
</button>
|
||
|
||
{/* 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>
|
||
)}
|
||
|
||
{src ? (
|
||
<video
|
||
ref={videoRef}
|
||
key={src}
|
||
src={src}
|
||
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}
|
||
onLoadStart={() => setBuffering(true)}
|
||
onWaiting={() => setBuffering(true)}
|
||
onStalled={() => setBuffering(true)}
|
||
onCanPlay={() => setBuffering(false)}
|
||
onPlaying={() => setBuffering(false)}
|
||
onSeeked={() => setBuffering(false)}
|
||
className="absolute inset-0 size-full object-contain"
|
||
>
|
||
{sidecars.map(([lang, file]) => (
|
||
<track
|
||
key={file}
|
||
kind="subtitles"
|
||
srcLang={lang}
|
||
label={lang}
|
||
src={fileUrl(file)}
|
||
/>
|
||
))}
|
||
</video>
|
||
) : (
|
||
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} mt-3 cursor-pointer py-1.5`}
|
||
>
|
||
Open on YouTube instead
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
)}
|
||
{src && !error && (
|
||
<div
|
||
className={`transition-opacity duration-200 ${
|
||
chromeVisible ? "opacity-100" : "pointer-events-none opacity-0"
|
||
}`}
|
||
>
|
||
<PlayerControls
|
||
videoRef={videoRef}
|
||
stageRef={stageRef}
|
||
onActivity={showChrome}
|
||
subLang={subLang}
|
||
onSubLang={onSubLang}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<footer
|
||
className="max-h-52 shrink-0 overflow-y-auto border-t border-slate-200 bg-white px-4 py-3
|
||
dark:border-slate-800 dark:bg-slate-900"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h2 className="truncate text-[15px] font-semibold tracking-tight">{item.title}</h2>
|
||
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
|
||
{[compactViews(item.views), relativeTime(item.published)]
|
||
.filter(Boolean)
|
||
.join(" · ")}
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
{onDownload && (
|
||
<button
|
||
onClick={onDownload}
|
||
disabled={downloading}
|
||
title={downloading ? "Downloading…" : "Download for offline"}
|
||
aria-label="Download"
|
||
className={navIcon}
|
||
>
|
||
{downloading ? (
|
||
<Spinner className="size-4" />
|
||
) : (
|
||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
)}
|
||
{!streaming && (
|
||
<button
|
||
onClick={onDelete}
|
||
title="Delete this download"
|
||
className={`${navBtn} whitespace-nowrap hover:border-red-500! hover:text-red-600!
|
||
dark:hover:border-red-500! dark:hover:text-red-400!`}
|
||
>
|
||
Delete
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
|
||
title="Open on YouTube"
|
||
aria-label="Open on YouTube"
|
||
className={navIcon}
|
||
>
|
||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path strokeLinecap="round" strokeLinejoin="round"
|
||
d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Collapsed by default — the description is rarely what you came for. */}
|
||
{item.description && (
|
||
<details className="group mt-2">
|
||
<summary
|
||
className="cursor-pointer list-none text-[11px] font-medium text-slate-500
|
||
hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400"
|
||
>
|
||
<span className="inline-block transition-transform group-open:rotate-90">›</span>{" "}
|
||
Description
|
||
</summary>
|
||
<p className="mt-2 whitespace-pre-wrap text-[12.5px] leading-relaxed text-slate-600 dark:text-slate-300">
|
||
{item.description}
|
||
</p>
|
||
</details>
|
||
)}
|
||
</footer>
|
||
</div>
|
||
);
|
||
}
|