Files
FlightTube/src/components/Player.tsx
T
vincent 7aae080e46 feat: durations, clean subtitles, icon buttons
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.
2026-08-29 14:07:17 +02:00

451 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}