feat: subtitles while streaming, and fetched when a download lacks them
YouTube's HLS manifest carries a dozen audio renditions and no subtitles whatsoever — checked against a live master playlist: 16 EXT-X-MEDIA entries, all TYPE=AUDIO, zero SUBTITLES. So the track menu could only ever list audio while streaming, which is what it did. Subtitles are now fetched separately with yt-dlp, tidied through the existing VTT cleanup, and cached per video and language. They reach the player as blob URLs, which share the document's origin — a file:// or 127.0.0.1 track would be cross-origin to the page and need CORS the media pipeline cannot supply. The same path fills in a download saved before subtitles were switched on, without fetching the video again. YouTube serves identical auto-generated captions under both "en" and "en-orig", so byte-identical texts collapse to one entry rather than offering the same track twice, and tracks are labelled "English" rather than "en". The subtitle preference now defaults to English. "None" is a poor default for a setting whose whole purpose is captions: it silently means no subtitles are downloaded, fetched, or offered anywhere, and the Settings text now says so.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fileUrl, listSubtitles, openExternal, resolveStream, savePlayback } from "../api";
|
||||
import {
|
||||
fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream, savePlayback,
|
||||
} from "../api";
|
||||
import type { FeedItem } from "../types";
|
||||
import { compactViews, relativeTime } from "./format";
|
||||
import { compactViews, relativeTime, subtitleLabel } from "./format";
|
||||
import PlayerControls from "./PlayerControls";
|
||||
import { Badge, BTN, Spinner } from "./ui";
|
||||
|
||||
@@ -127,6 +129,41 @@ export default function Player({
|
||||
cancelled = true;
|
||||
};
|
||||
}, [item.id, path]);
|
||||
// Captions fetched on demand, as (language, blob URL). YouTube's HLS
|
||||
// manifest carries a dozen audio renditions and no subtitles at all, so a
|
||||
// stream has nothing to show without this; a download saved before subtitles
|
||||
// were switched on is in the same position. Blob URLs share the document's
|
||||
// origin, which a file:// or 127.0.0.1 track would not.
|
||||
const [fetched, setFetched] = useState<Array<[string, string]>>([]);
|
||||
const [fetchingSubs, setFetchingSubs] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFetched([]);
|
||||
if (subLang === "off" || sidecars.length > 0) return;
|
||||
const urls: string[] = [];
|
||||
let cancelled = false;
|
||||
setFetchingSubs(true);
|
||||
fetchSubtitles(item.id, subLang)
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setFetched(
|
||||
list.map(([lang, text]) => {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: "text/vtt" }));
|
||||
urls.push(url);
|
||||
return [lang, url] as [string, string];
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
/* no captions in this language is an ordinary outcome */
|
||||
})
|
||||
.finally(() => !cancelled && setFetchingSubs(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const u of urls) URL.revokeObjectURL(u);
|
||||
};
|
||||
}, [item.id, subLang, sidecars.length]);
|
||||
|
||||
// Controls and edge arrows fade away while you are just watching.
|
||||
const [chromeVisible, setChromeVisible] = useState(true);
|
||||
const hideTimer = useRef<number | undefined>(undefined);
|
||||
@@ -362,10 +399,19 @@ export default function Player({
|
||||
key={file}
|
||||
kind="subtitles"
|
||||
srcLang={lang}
|
||||
label={lang}
|
||||
label={subtitleLabel(lang)}
|
||||
src={fileUrl(file)}
|
||||
/>
|
||||
))}
|
||||
{fetched.map(([lang, url]) => (
|
||||
<track
|
||||
key={url}
|
||||
kind="subtitles"
|
||||
srcLang={lang}
|
||||
label={subtitleLabel(lang)}
|
||||
src={url}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
) : (
|
||||
error && (
|
||||
@@ -394,6 +440,7 @@ export default function Player({
|
||||
onActivity={showChrome}
|
||||
subLang={subLang}
|
||||
onSubLang={onSubLang}
|
||||
subsLoading={fetchingSubs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user