feat: embedded subtitles, resumed downloads, subtitle-only track menu
Verified in the running app and against the files on disk. Subtitles are muxed into the download as a soft SubRip track rather than written beside it. Without --write-subs, yt-dlp fetches them, embeds them and removes the WebVTT, so a download is one file — checked with ffprobe: video, audio, one mov_text track, no sidecar. WebKit exposes it, so the player lists and shows it with no fetch at all, which also means it works offline. They are converted to SubRip on the way in. YouTube's WebVTT pins every cue to the left edge and fills it with karaoke timing tags; the first embed carried both and rendered clamped to the side of the picture. SubRip carries neither. Only the plain language is embedded — "en" and "en-orig" are usually the same captions, and nothing can collapse duplicates once they are muxed in. Downloads left unfinished when the app closes are picked up on the next launch, at the quality and language they were asked for, which the downloads table now records. Verified by leaving a row "running" with no process behind it and restarting: it downloaded and completed. The player's track menu is subtitles only — the audio renditions are gone, the original-audio default still being fixed in the manifest — and tracks read "English" rather than "en". Turning subtitles off in one video now sticks for the next. The guard that applies the preference keyed on the number of text tracks alone, which does not change from one video to the next, so a choice of Off was never re-applied; it keys on the video as well. Embedding no longer depends on the on/off preference, the same trap that kept fetching switched off: a file downloaded without a subtitle track can never gain one offline. Delete all is a trash icon.
This commit is contained in:
+37
-5
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo,
|
||||
interruptedDownloads,
|
||||
fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource,
|
||||
} from "./api";
|
||||
import Player from "./components/Player";
|
||||
@@ -148,6 +149,11 @@ export default function App() {
|
||||
}, [view, quality, bulkLimit, streamQuality, subLang, browser, downloadedOnly,
|
||||
hideShorts, sidebarHidden]);
|
||||
|
||||
// The language a download embeds. Like the player's fetch, this does not
|
||||
// depend on the on/off preference: that says what is shown, and a file
|
||||
// downloaded without a subtitle track can never gain one offline.
|
||||
const embedLang = subLang === "off" ? DEFAULT_SUB_LANG : subLang;
|
||||
|
||||
// Offline, the only videos that can be played are the ones already on disk,
|
||||
// so the feed collapses to those regardless of the toggle.
|
||||
const effectiveDownloadedOnly = downloadedOnly || !online;
|
||||
@@ -191,7 +197,6 @@ export default function App() {
|
||||
// Named exactly, never as a wildcard: "en.*" also matches every
|
||||
// machine-translated variant YouTube offers, and asking for all of them
|
||||
// earns an HTTP 429. Off means no subtitle requests at all.
|
||||
const subLangArg = subLang === "off" ? "" : `${subLang},${subLang}-orig`;
|
||||
|
||||
const doRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
@@ -275,6 +280,33 @@ export default function App() {
|
||||
return ids.size;
|
||||
}, [live, items]);
|
||||
|
||||
// Closing the app kills yt-dlp, so anything in flight is left unfinished.
|
||||
// Pick it up on the next launch, at the quality and language it was asked
|
||||
// for rather than whatever the settings happen to say now.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
interruptedDownloads()
|
||||
.then((rows) => {
|
||||
if (cancelled || rows.length === 0) return;
|
||||
say(
|
||||
rows.length === 1
|
||||
? "Resuming 1 unfinished download"
|
||||
: `Resuming ${rows.length} unfinished downloads`,
|
||||
);
|
||||
for (const [id, q, lang] of rows) {
|
||||
downloadVideo(id, (q || "best") as Quality, lang).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* nothing to resume is the normal case */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Once, on launch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const stopAll = useCallback(() => {
|
||||
cancelAllDownloads()
|
||||
.then((n) => {
|
||||
@@ -311,9 +343,9 @@ export default function App() {
|
||||
: `Queued ${bulkTargets.length} video${bulkTargets.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
for (const i of bulkTargets) {
|
||||
downloadVideo(i.id, quality, subLangArg).catch(() => {});
|
||||
downloadVideo(i.id, quality, embedLang).catch(() => {});
|
||||
}
|
||||
}, [bulkTargets, pendingDownloads.length, quality, subLangArg, say]);
|
||||
}, [bulkTargets, pendingDownloads.length, quality, embedLang, say]);
|
||||
|
||||
// Ids on screen still lacking a length, newest first. Joined into a string
|
||||
// so the effect below only re-runs when the set actually changes.
|
||||
@@ -502,7 +534,7 @@ export default function App() {
|
||||
onOpen: () => openIndex(idx),
|
||||
onOpenChannel: () => { setChannelId(item.channel_id); setSearch(""); },
|
||||
onDownload: () =>
|
||||
downloadVideo(item.id, quality, subLangArg).catch((e) => setFailure(String(e))),
|
||||
downloadVideo(item.id, quality, embedLang).catch((e) => setFailure(String(e))),
|
||||
onCancel: () =>
|
||||
cancelDownload(item.id).catch((e) => setFailure(String(e))),
|
||||
onDelete: () =>
|
||||
@@ -546,7 +578,7 @@ export default function App() {
|
||||
onDownload={
|
||||
playing.path === null
|
||||
? () => {
|
||||
downloadVideo(playing.item.id, quality, subLangArg).catch((e) => setFailure(String(e)));
|
||||
downloadVideo(playing.item.id, quality, embedLang).catch((e) => setFailure(String(e)));
|
||||
say("Download started");
|
||||
}
|
||||
: undefined
|
||||
|
||||
+6
-2
@@ -25,8 +25,12 @@ export const listFeed = (filter: FeedFilter) =>
|
||||
|
||||
export const refreshFeeds = () => invoke<RefreshSummary>("refresh_feeds");
|
||||
|
||||
export const downloadVideo = (videoId: string, quality: Quality, subLangs: string) =>
|
||||
invoke<void>("download_video", { videoId, quality, subLangs });
|
||||
export const downloadVideo = (videoId: string, quality: Quality, subLang: string) =>
|
||||
invoke<void>("download_video", { videoId, quality, subLang });
|
||||
|
||||
/** Downloads still unfinished from a previous run, as (id, quality, subLang). */
|
||||
export const interruptedDownloads = () =>
|
||||
invoke<Array<[string, string, string]>>("interrupted_downloads");
|
||||
|
||||
/** Stops everything downloading or waiting to. Returns how many were stopped. */
|
||||
export const cancelAllDownloads = () => invoke<number>("cancel_all_downloads");
|
||||
|
||||
@@ -143,6 +143,21 @@ export default function Player({
|
||||
setTracks([]);
|
||||
setFetchingSubs(false);
|
||||
|
||||
// A download made since subtitles became embedded carries them inside the
|
||||
// file, where the element exposes them itself. Give it until the metadata
|
||||
// is parsed to say so before going to the network for something already on
|
||||
// disk — which would also fail offline.
|
||||
const hasEmbedded = async () => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return false;
|
||||
for (let i = 0, settled = 0; i < 20 && settled < 3; i++) {
|
||||
if (v.textTracks.length > 0) return true;
|
||||
if (v.readyState >= 1) settled++;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
return (videoRef.current?.textTracks.length ?? 0) > 0;
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (path) {
|
||||
const local = await listSubtitles(item.id).catch(() => []);
|
||||
@@ -151,6 +166,7 @@ export default function Player({
|
||||
setTracks(local.map(([lang, file]) => [lang, fileUrl(file)]));
|
||||
return;
|
||||
}
|
||||
if (await hasEmbedded()) return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
setFetchingSubs(true);
|
||||
|
||||
@@ -1,25 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/** WebKit exposes HLS alternate audio renditions here; the DOM lib omits it. */
|
||||
interface AudioTrackLike {
|
||||
id: string;
|
||||
label: string;
|
||||
language: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
interface AudioTrackListLike {
|
||||
length: number;
|
||||
[index: number]: AudioTrackLike;
|
||||
addEventListener?: (t: string, fn: () => void) => void;
|
||||
removeEventListener?: (t: string, fn: () => void) => void;
|
||||
}
|
||||
type VideoWithTracks = HTMLVideoElement & { audioTracks?: AudioTrackListLike };
|
||||
|
||||
function listAudio(v: HTMLVideoElement | null): AudioTrackLike[] {
|
||||
const list = (v as VideoWithTracks | null)?.audioTracks;
|
||||
if (!list) return [];
|
||||
return Array.from({ length: list.length }, (_, i) => list[i]);
|
||||
}
|
||||
import { subtitleLabel } from "./format";
|
||||
|
||||
function listSubs(v: HTMLVideoElement | null): TextTrack[] {
|
||||
if (!v) return [];
|
||||
@@ -76,21 +57,17 @@ export default function PlayerControls({
|
||||
const [pip, setPip] = useState(false);
|
||||
const [full, setFull] = useState(false);
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [audio, setAudio] = useState<AudioTrackLike[]>([]);
|
||||
const [subs, setSubs] = useState<TextTrack[]>([]);
|
||||
const [, bump] = useState(0);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
// How many text tracks the preference was last applied to.
|
||||
const applied = useRef(-1);
|
||||
// Which video, and how many tracks, the preference was last applied to.
|
||||
const applied = useRef("");
|
||||
|
||||
// Tracks arrive with the manifest, after metadata rather than on mount.
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const read = () => {
|
||||
setAudio(listAudio(v));
|
||||
setSubs(listSubs(v));
|
||||
};
|
||||
const read = () => setSubs(listSubs(v));
|
||||
read();
|
||||
|
||||
// Subtitles follow the language chosen in Settings and nothing else.
|
||||
@@ -98,11 +75,12 @@ export default function PlayerControls({
|
||||
// which is the same unwanted auto-selection as a dubbed audio track.
|
||||
// Applied once per set of tracks. Re-applying on every poll would undo a
|
||||
// choice made in the menu a second after it was made.
|
||||
applied.current = -1;
|
||||
applied.current = "";
|
||||
const applyPreference = () => {
|
||||
const tracks = Array.from(v.textTracks);
|
||||
if (tracks.length === applied.current) return;
|
||||
applied.current = tracks.length;
|
||||
const key = `${v.currentSrc}|${tracks.length}`;
|
||||
if (key === applied.current) return;
|
||||
applied.current = key;
|
||||
const wanted =
|
||||
subLang === "off"
|
||||
? undefined
|
||||
@@ -118,8 +96,6 @@ export default function PlayerControls({
|
||||
v.textTracks.addEventListener?.("addtrack", applyPreference);
|
||||
|
||||
v.addEventListener("loadedmetadata", read);
|
||||
const at = (v as VideoWithTracks).audioTracks;
|
||||
at?.addEventListener?.("addtrack", read);
|
||||
v.textTracks.addEventListener?.("addtrack", read);
|
||||
// Manifests can take a moment to surface renditions.
|
||||
const id = setInterval(read, 1000);
|
||||
@@ -128,7 +104,6 @@ export default function PlayerControls({
|
||||
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);
|
||||
@@ -145,16 +120,6 @@ export default function PlayerControls({
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [menu]);
|
||||
|
||||
const chooseAudio = (i: number) => {
|
||||
const v = videoRef.current;
|
||||
const list = (v as VideoWithTracks | null)?.audioTracks;
|
||||
if (!list) return;
|
||||
// Exactly one enabled, or WebKit mixes them.
|
||||
for (let k = 0; k < list.length; k++) list[k].enabled = k === i;
|
||||
bump((n) => n + 1);
|
||||
onActivity?.();
|
||||
};
|
||||
|
||||
const chooseSub = (track: TextTrack | null) => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
@@ -340,12 +305,12 @@ export default function PlayerControls({
|
||||
}}
|
||||
/>
|
||||
|
||||
{(audio.length > 1 || subs.length > 0 || subsLoading) && (
|
||||
{(subs.length > 0 || subsLoading) && (
|
||||
<div ref={menuRef} className="relative shrink-0">
|
||||
<button
|
||||
onClick={() => { setMenu((m) => !m); onActivity?.(); }}
|
||||
className={btn}
|
||||
title="Audio and subtitles"
|
||||
title="Subtitles"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
@@ -358,27 +323,6 @@ export default function PlayerControls({
|
||||
className="absolute bottom-full right-0 mb-2 max-h-72 w-60 overflow-y-auto rounded-lg
|
||||
border border-slate-700 bg-slate-900/95 p-1 shadow-2xl backdrop-blur"
|
||||
>
|
||||
{audio.length > 1 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||
Audio
|
||||
</div>
|
||||
{audio.map((t, i) => (
|
||||
<button
|
||||
key={t.id || `${t.language}-${i}`}
|
||||
onClick={() => chooseAudio(i)}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left
|
||||
text-[12px] cursor-pointer hover:bg-slate-800 ${
|
||||
t.enabled ? "text-white" : "text-slate-300"
|
||||
}`}
|
||||
>
|
||||
<span className="w-3 shrink-0 text-sky-400">{t.enabled ? "✓" : ""}</span>
|
||||
<span className="truncate">{t.label || t.language || `Track ${i + 1}`}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{subs.length === 0 && subsLoading && (
|
||||
<>
|
||||
<div className="mt-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||
@@ -419,7 +363,11 @@ export default function PlayerControls({
|
||||
<span className="w-3 shrink-0 text-sky-400">
|
||||
{t.mode === "showing" ? "✓" : ""}
|
||||
</span>
|
||||
<span className="truncate">{t.label || t.language || `Subtitles ${i + 1}`}</span>
|
||||
<span className="truncate">
|
||||
{t.language
|
||||
? subtitleLabel(t.language)
|
||||
: t.label || `Subtitles ${i + 1}`}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -280,8 +280,8 @@ export default function Settings({
|
||||
captions — on most videos those are the only ones there are. Downloads keep
|
||||
them beside the file for offline use; streams fetch them separately, since
|
||||
YouTube's live manifest carries no subtitles at all. <b>Off</b> only means
|
||||
nothing comes on by itself: whatever a video has is still listed in the
|
||||
player's subtitle menu.
|
||||
nothing comes on by itself: subtitles are still embedded in downloads and
|
||||
still listed in the player's subtitle menu.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -162,13 +162,17 @@ export default function TopBar({
|
||||
<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`}
|
||||
aria-label="Delete every download"
|
||||
className={`${ICON_BTN} border border-slate-300 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
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 7h16M10 11v6M14 11v6" />
|
||||
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
|
||||
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user