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:
+56
-15
@@ -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>
|
||||
|
||||
@@ -34,6 +34,8 @@ interface Props {
|
||||
stageRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Nudges the auto-hide timer whenever the user does something. */
|
||||
onActivity?: () => void;
|
||||
/** Preferred subtitle language, or "off" to start with none. */
|
||||
subLang: string;
|
||||
}
|
||||
|
||||
const SKIP_S = 10;
|
||||
@@ -59,7 +61,7 @@ const btn =
|
||||
* Owning the bar is the only way to get every control into one strip along the
|
||||
* bottom, so the native ones are switched off entirely.
|
||||
*/
|
||||
export default function PlayerControls({ videoRef, stageRef, onActivity }: Props) {
|
||||
export default function PlayerControls({ videoRef, stageRef, onActivity, subLang }: Props) {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [time, setTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
@@ -83,15 +85,24 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
||||
};
|
||||
read();
|
||||
|
||||
// Nothing translated gets forced on. YouTube marks its subtitle track
|
||||
// AUTOSELECT=YES and WebKit will switch it on when it matches the system
|
||||
// language; that is the same unwanted auto-selection as a dubbed audio
|
||||
// track, so subtitles start off and stay a deliberate choice.
|
||||
const silenceSubs = () => {
|
||||
for (const t of Array.from(v.textTracks)) t.mode = "disabled";
|
||||
// Subtitles follow the language chosen in Settings and nothing else.
|
||||
// WebKit will otherwise switch on whatever matches the system language,
|
||||
// which is the same unwanted auto-selection as a dubbed audio track.
|
||||
const applyPreference = () => {
|
||||
const tracks = Array.from(v.textTracks);
|
||||
const wanted =
|
||||
subLang === "off"
|
||||
? undefined
|
||||
: tracks.find((t) =>
|
||||
(t.language || "").toLowerCase().startsWith(subLang.toLowerCase()),
|
||||
);
|
||||
for (const t of tracks) t.mode = t === wanted ? "showing" : "disabled";
|
||||
read();
|
||||
};
|
||||
v.addEventListener("loadedmetadata", silenceSubs);
|
||||
applyPreference();
|
||||
v.addEventListener("loadedmetadata", applyPreference);
|
||||
// HLS subtitle renditions arrive after metadata, so re-apply as they land.
|
||||
v.textTracks.addEventListener?.("addtrack", applyPreference);
|
||||
|
||||
v.addEventListener("loadedmetadata", read);
|
||||
const at = (v as VideoWithTracks).audioTracks;
|
||||
@@ -101,14 +112,15 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
||||
const id = setInterval(read, 1000);
|
||||
const stop = setTimeout(() => clearInterval(id), 8000);
|
||||
return () => {
|
||||
v.removeEventListener("loadedmetadata", silenceSubs);
|
||||
v.removeEventListener("loadedmetadata", applyPreference);
|
||||
v.textTracks.removeEventListener?.("addtrack", applyPreference);
|
||||
v.removeEventListener("loadedmetadata", read);
|
||||
at?.removeEventListener?.("addtrack", read);
|
||||
v.textTracks.removeEventListener?.("addtrack", read);
|
||||
clearInterval(id);
|
||||
clearTimeout(stop);
|
||||
};
|
||||
}, [videoRef]);
|
||||
}, [videoRef, subLang]);
|
||||
|
||||
// Close the menu on any outside click.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
} from "../api";
|
||||
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
|
||||
import {
|
||||
QUALITIES, STREAM_QUALITIES, type ImportPreview, type Prereqs, type Quality,
|
||||
QUALITIES, STREAM_QUALITIES, SUB_LANGS,
|
||||
type ImportPreview, type Prereqs, type Quality,
|
||||
} from "../types";
|
||||
import TakeoutGuide from "./TakeoutGuide";
|
||||
import {
|
||||
@@ -20,6 +21,8 @@ interface Props {
|
||||
onQuality: (q: Quality) => void;
|
||||
streamQuality: Quality;
|
||||
onStreamQuality: (q: Quality) => void;
|
||||
subLang: string;
|
||||
onSubLang: (l: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
@@ -44,7 +47,7 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
|
||||
|
||||
export default function Settings({
|
||||
onClose, onImported, appearance, onAppearance, quality, onQuality,
|
||||
streamQuality, onStreamQuality, onError,
|
||||
streamQuality, onStreamQuality, subLang, onSubLang, onError,
|
||||
}: Props) {
|
||||
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
|
||||
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
|
||||
@@ -182,6 +185,26 @@ export default function Settings({
|
||||
Streaming quality applies when you play something you have not downloaded.
|
||||
Best lets the player adapt to your connection; a fixed height pins it.
|
||||
</p>
|
||||
|
||||
<label className="mt-3 grid grid-cols-[92px_1fr] items-center gap-2">
|
||||
<span className={LABEL}>Subtitles</span>
|
||||
<select
|
||||
value={subLang}
|
||||
onChange={(e) => onSubLang(e.target.value)}
|
||||
className={SELECT}
|
||||
>
|
||||
{SUB_LANGS.map((l) => (
|
||||
<option key={l.value} value={l.value}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className={`mt-2 ${HELP}`}>
|
||||
Shown automatically when a video has subtitles in this language, including
|
||||
YouTube's auto-generated ones, and saved alongside anything you download so
|
||||
they work offline. You can still switch tracks from the player.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||
|
||||
@@ -21,6 +21,8 @@ interface Props {
|
||||
onView: (v: ViewMode) => void;
|
||||
sidebarHidden: boolean;
|
||||
onShowSidebar: () => void;
|
||||
/** Present only when there is something to delete. */
|
||||
onDeleteAll?: () => void;
|
||||
/** Matches the sidebar's inset so the two headers share a baseline. */
|
||||
titleBarInset: boolean;
|
||||
}
|
||||
@@ -58,7 +60,7 @@ export default function TopBar({
|
||||
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
|
||||
online, reachable, forcedOffline, onToggleForcedOffline,
|
||||
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
|
||||
sidebarHidden, onShowSidebar, titleBarInset,
|
||||
sidebarHidden, onShowSidebar, onDeleteAll, titleBarInset,
|
||||
}: Props) {
|
||||
const pct = refreshProgress && refreshProgress.total > 0
|
||||
? (refreshProgress.done / refreshProgress.total) * 100
|
||||
@@ -105,6 +107,20 @@ export default function TopBar({
|
||||
Downloaded only
|
||||
</Toggle>
|
||||
|
||||
{downloadedOnly && onDeleteAll && (
|
||||
<button
|
||||
onClick={onDeleteAll}
|
||||
title="Delete every download"
|
||||
className={`inline-flex ${CONTROL_H} cursor-pointer items-center whitespace-nowrap
|
||||
rounded-lg border border-slate-300 px-2.5 text-[12px] font-medium
|
||||
text-slate-500 hover:border-red-500 hover:text-red-600
|
||||
dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-500
|
||||
dark:hover:text-red-400`}
|
||||
>
|
||||
Delete all
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Toggle active={hideShorts} onClick={() => onHideShorts(!hideShorts)}
|
||||
title="Hide Shorts from the feed">
|
||||
Hide Shorts
|
||||
|
||||
Reference in New Issue
Block a user