feat: subtitles, delete-all, auto refresh, and menu cleanup

Subtitles: a language preference in Settings drives what is shown and
what is downloaded. yt-dlp saves WebVTT sidecars next to each download,
including YouTube's auto-generated track, and the player attaches them
as <track> elements so they work offline. Sidecars are removed with
their video, and by Delete all.

WebVTT rather than muxed subtitle streams because WebKit reads a <track>
reliably and largely ignores subtitle tracks inside an MP4.

Downloads in progress now appear under the downloaded-only filter, so a
download you just started does not vanish from the list you are watching
it in. That view also gains a Delete all, behind a confirmation naming
what goes.

The feed refreshes on launch and whenever the player closes, so the
Refresh button is only for staleness.

Player: Delete moved to the footer and shortened, Open on YouTube is now
an external-link icon.

Removes the Edit and Help menus. Cut/Copy/Paste move to the app menu,
without which their shortcuts would stop working in the search field.
The webview context menu is suppressed outside text fields — its Reload
and Back items act on a page the app does not present as one.

Settings now warns that 4K AV1 plays back with artefacts: the files
decode cleanly in ffmpeg, so it is the built-in decoder, not the
download.
This commit is contained in:
vincent
2026-08-29 13:25:17 +02:00
parent 73f12cfac1
commit 557467baad
12 changed files with 458 additions and 43 deletions
+56 -15
View File
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fileUrl, openExternal, resolveStream, savePlayback } from "../api";
import { fileUrl, listSubtitles, openExternal, resolveStream, savePlayback } from "../api";
import type { FeedItem } from "../types";
import { compactViews, relativeTime } from "./format";
import PlayerControls from "./PlayerControls";
import { BTN, BTN_CHROME, Spinner } from "./ui";
import { BTN, Spinner } from "./ui";
interface Props {
item: FeedItem;
@@ -18,6 +18,8 @@ interface Props {
downloading?: boolean;
/** Max height for streaming, or null to let the player adapt. */
maxHeight: number | null;
/** Preferred subtitle language, or "off". */
subLang: string;
/** Position in the current feed, for the "3 of 180" readout. */
index: number;
total: number;
@@ -97,12 +99,28 @@ const RESUME_EDGE_S = 5;
*/
export default function Player({
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
maxHeight, index, total, titleBarInset,
maxHeight, subLang, 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);
@@ -236,17 +254,10 @@ export default function Player({
{item.channel_title}
</span>
{streaming ? (
{streaming && (
<span className="text-[11px] text-slate-400 dark:text-slate-500">
{error ? "Unavailable" : src && !buffering ? "Streaming" : "Loading…"}
</span>
) : (
<button
onClick={onDelete}
className={`${BTN_CHROME} cursor-pointer hover:bg-red-500/10! hover:text-red-600! dark:hover:text-red-400!`}
>
Delete download
</button>
)}
</header>
@@ -312,7 +323,17 @@ export default function Player({
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">
@@ -334,7 +355,12 @@ export default function Player({
chromeVisible ? "opacity-100" : "pointer-events-none opacity-0"
}`}
>
<PlayerControls videoRef={videoRef} stageRef={stageRef} onActivity={showChrome} />
<PlayerControls
videoRef={videoRef}
stageRef={stageRef}
onActivity={showChrome}
subLang={subLang}
/>
</div>
)}
</div>
@@ -362,11 +388,26 @@ export default function Player({
{downloading ? "Downloading…" : "Download"}
</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}`)}
className={`${navBtn} whitespace-nowrap`}
title="Open on YouTube"
aria-label="Open on YouTube"
className={`${navBtn} w-[30px] p-0`}
>
Open on YouTube
<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>