Files
FlightTube/src/components/Player.tsx
T
vincent 14dab91334 feat: the player's title opens the description
The title is the link now, and looks like nothing: it keeps its colour
and carries no underline, since dressing it up would compete with the
video for attention. The pointer and the tooltip say the rest.

Views and age move onto that line, in the same small quiet grey, and
the "› Description" disclosure underneath is gone — one line instead of
two, and the description gets a proper modal with room to read it.

Addresses in a description are plain text as YouTube stores them; they
are found and made clickable, and open in the real browser. Trailing
punctuation is trimmed from the target: a full stop ends the sentence,
not the address.

Verified in the running app on a description with three consecutive
affiliate links, all three of which came out as links — the test for
one uses startsWith rather than a global regex, whose lastIndex carries
between calls and would have left every other address as plain text.
2026-09-03 23:47:27 +02:00

641 lines
24 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 {
embeddedSubtitles, fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream,
savePlayback,
} from "../api";
import { DEFAULT_SUB_LANG, SUB_FONTS, SUB_PLACES, type FeedItem, type SubStyle } from "../types";
import { compactViews, relativeTime, subtitleLabel } from "./format";
import PlayerControls from "./PlayerControls";
import { Badge, BTN, Dialog, 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;
subStyle: SubStyle;
onSubStyle: (s: SubStyle) => void;
/** Leaves the player for this video's channel. */
onOpenChannel: () => void;
/** Roll straight on to the next video when this one ends. */
autoplayNext: boolean;
/** 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".
*/
/** Web addresses in a YouTube description, which are plain text as it stores them. */
const URL_IN_TEXT = /(https?:\/\/[^\s<>"']+)/g;
/**
* A description with its addresses made clickable.
*
* They open in the real browser: a YouTube link is the one thing in here this
* app has no way to show, and the rest belong to whoever wrote them.
*/
function Linked({ text }: { text: string }) {
return (
<>
{text.split(URL_IN_TEXT).map((part, i) =>
// Not URL_IN_TEXT.test: a global regex carries lastIndex between calls,
// so every other address would come out as plain text.
part.startsWith("http") ? (
// Trailing punctuation is sentence, not address.
<button
key={i}
onClick={() => openExternal(part.replace(/[.,;:!?)\]]+$/, ""))}
title={part}
className="cursor-pointer break-all text-left text-sky-600 underline
underline-offset-2 hover:text-sky-500 dark:text-sky-400"
>
{part}
</button>
) : (
<span key={i}>{part}</span>
),
)}
</>
);
}
/**
* Rewrites every cue's settings to one placement.
*
* WebVTT carries position on the cue itself, so this is the only way to move
* subtitles: no CSS property places them. Existing settings are dropped rather
* than merged — YouTube's own are exactly what needs overriding.
*/
function placeCues(vtt: string, line: number | null): string {
return vtt.replace(
/^(\s*[\d:.]+\s+-->\s+[\d:.]+)(.*)$/gm,
(_m, times: string) => (line == null ? times : `${times} line:${line}%`),
);
}
export default function Player({
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
maxHeight, subLang, onSubLang, subStyle, onSubStyle, onOpenChannel, autoplayNext,
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);
// The height actually being decoded. With an adaptive stream this changes as
// the player switches rendition, so it is read from the element rather than
// assumed from the setting.
const [height, setHeight] = useState(0);
/**
* Subtitle tracks as (language, URL), from whichever source has them:
* WebVTT files yt-dlp saved beside a download, or a fetch.
*
* A stream has no other source — YouTube's HLS manifest carries a dozen
* audio renditions and no subtitles at all — and a download saved before
* subtitles were switched on has nothing beside it either.
*
* Fetching does not depend on the Settings preference. That preference says
* which language is switched on by itself; it must not decide whether
* subtitles exist to be chosen at all, or "None" would quietly empty the
* player's subtitle menu as well.
*
* Fetched cues become blob URLs, which share the document's origin — a
* file:// or 127.0.0.1 track would not.
*/
const [rawTracks, setRawTracks] = useState<Array<[string, string]>>([]);
const [tracks, setTracks] = useState<Array<[string, string]>>([]);
const [fetchingSubs, setFetchingSubs] = useState(false);
const [showDescription, setShowDescription] = useState(false);
// The language to fetch, which is NOT the preference: turning subtitles on
// from the player's menu moves the preference from "off" to that language,
// and refetching then would tear down the tracks — and the stream with them —
// the instant one is chosen.
const wantLang = subLang === "off" ? DEFAULT_SUB_LANG : subLang;
useEffect(() => {
let cancelled = false;
setRawTracks([]);
setFetchingSubs(false);
const load = async () => {
if (path) {
// Muxed into the download, which is where they belong — but read out
// rather than left to the element's in-band rendering, which the media
// pipeline draws wherever the container's text box points and which no
// styling can reach.
const inside = await embeddedSubtitles(item.id).catch(() => []);
if (cancelled) return;
if (inside.length > 0) {
setRawTracks(inside);
return;
}
// Downloads from before subtitles were embedded kept them beside the
// file instead.
const local = await listSubtitles(item.id).catch(() => []);
if (cancelled) return;
if (local.length > 0) {
setRawTracks(local);
return;
}
}
if (cancelled) return;
setFetchingSubs(true);
try {
const list = await fetchSubtitles(item.id, wantLang);
if (cancelled) return;
setRawTracks(list);
} catch {
/* a video with no captions in this language is an ordinary outcome */
} finally {
if (!cancelled) setFetchingSubs(false);
}
};
void load();
return () => {
cancelled = true;
};
}, [item.id, path, wantLang]);
// Placement is a WebVTT cue setting, not something CSS can reach, so it is
// written into the cues themselves. Re-cut whenever the choice changes.
useEffect(() => {
const line = SUB_PLACES.find((p) => p.value === subStyle.place)?.line ?? null;
const urls: string[] = [];
setTracks(
rawTracks.map(([lang, text]) => {
const url = URL.createObjectURL(
new Blob([placeCues(text, line)], { type: "text/vtt" }),
);
urls.push(url);
return [lang, url] as [string, string];
}),
);
return () => {
for (const u of urls) URL.revokeObjectURL(u);
};
}, [rawTracks, subStyle.place]);
// 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);
// Quality is the short side, so a portrait Short reads 1080p, not 1920p.
const measure = useCallback(() => {
const v = videoRef.current;
if (!v?.videoHeight) return;
setHeight(Math.min(v.videoWidth, v.videoHeight));
}, []);
const stageRef = useRef<HTMLDivElement>(null);
const lastSave = useRef(0);
useEffect(() => {
setHeight(0);
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, Escape is the transport bar's to handle: it leaves
// fullscreen. Closing the player as well would drop you all the way back
// to the feed in one keypress.
if (e.key === "Escape" && !document.fullscreenElement) leave();
// Moving between videos. Shift with the arrows because the bare ones
// seek, and shift with N and P because that is what YouTube uses.
if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "P" || e.key === "p")) onPrev?.();
if (e.shiftKey && (e.key === "ArrowRight" || e.key === "N" || e.key === "n")) 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>
{/* A tag rather than a caption: it goes somewhere. */}
<div className="flex min-w-0 flex-1 items-center">
<button
onClick={onOpenChannel}
title={`Show everything from ${item.channel_title}`}
className="max-w-full cursor-pointer truncate rounded-full border border-slate-300
px-2.5 py-0.5 text-[12px] text-slate-500 transition-colors
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"
>
{item.channel_title}
</button>
</div>
{streaming ? (
error ? (
<Badge tone="danger">Unavailable</Badge>
) : src && !buffering ? (
<Badge tone="accent" title={`Streaming at ${height || "an unknown"}p`}>
Streaming{height ? ` · ${height}p` : ""}
</Badge>
) : (
<Badge>Loading</Badge>
)
) : (
height > 0 && (
<Badge title={`This copy on your Mac is ${height}p`}>
Downloaded · {height}p
</Badge>
)
)}
</header>
<style>{`video::cue {
font-size: ${subStyle.size}%;
font-family: ${SUB_FONTS.find((f) => f.value === subStyle.font)?.stack ?? "sans-serif"};
}`}</style>
{/* 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}
onEnded={() => {
persist();
// Only where there is somewhere to go: the last video in the
// list simply stops.
if (autoplayNext) onNext?.();
}}
onLoadStart={() => setBuffering(true)}
onWaiting={() => setBuffering(true)}
onStalled={() => setBuffering(true)}
onCanPlay={() => setBuffering(false)}
onPlaying={() => setBuffering(false)}
onSeeked={() => setBuffering(false)}
onResize={measure}
onLoadedData={measure}
className="absolute inset-0 size-full object-contain"
>
{tracks.map(([lang, url]) => (
<track key={url} kind="subtitles" srcLang={lang} label={subtitleLabel(lang)} src={url} />
))}
</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}
subStyle={subStyle}
onSubStyle={onSubStyle}
subsLoading={fetchingSubs}
/>
</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">
{/* Title and figures on one line. The title opens the description
rather than announcing itself as a link: it keeps its colour and
stays unadorned, and the pointer says the rest. */}
<div className="flex min-w-0 items-baseline gap-2">
{item.description ? (
<button
onClick={() => setShowDescription(true)}
title="Show the description"
className="min-w-0 cursor-pointer truncate text-left text-[15px] font-semibold
tracking-tight"
>
{item.title}
</button>
) : (
<h2 className="min-w-0 truncate text-[15px] font-semibold tracking-tight">
{item.title}
</h2>
)}
<span className="shrink-0 text-[11px] text-slate-400 dark:text-slate-500">
{[compactViews(item.views), relativeTime(item.published)]
.filter(Boolean)
.join(" · ")}
</span>
</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"
aria-label="Delete download"
className={`${navIcon} hover:border-red-500! hover:text-red-600!
dark:hover:border-red-500! dark:hover:text-red-400!`}
>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round"
d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6" />
</svg>
</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>
</footer>
{showDescription && (
<Dialog title={item.title} onCancel={() => setShowDescription(false)} wide>
<p className="whitespace-pre-wrap text-[12.5px] leading-relaxed">
<Linked text={item.description} />
</p>
</Dialog>
)}
</div>
);
}