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:
+4
-3
@@ -16,7 +16,8 @@ import { useDownloads } from "./hooks/useDownloads";
|
||||
import { useFeed } from "./hooks/useFeed";
|
||||
import { useWindowFullscreen } from "./hooks/useWindowFullscreen";
|
||||
import {
|
||||
BULK_LIMITS, DEFAULT_BULK_LIMIT, QUALITIES, STREAM_QUALITIES, SUB_LANGS,
|
||||
BULK_LIMITS, DEFAULT_BULK_LIMIT, DEFAULT_SUB_LANG,
|
||||
QUALITIES, STREAM_QUALITIES, SUB_LANGS,
|
||||
type FeedFilter, type FeedItem, type Quality, type RefreshProgress,
|
||||
} from "./types";
|
||||
|
||||
@@ -66,9 +67,9 @@ export default function App() {
|
||||
const [subLang, setSubLang] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("flighttube.subLang");
|
||||
return SUB_LANGS.some((l) => l.value === stored) ? stored! : "off";
|
||||
return SUB_LANGS.some((l) => l.value === stored) ? stored! : DEFAULT_SUB_LANG;
|
||||
} catch {
|
||||
return "off";
|
||||
return DEFAULT_SUB_LANG;
|
||||
}
|
||||
});
|
||||
const [streamQuality, setStreamQuality] = useState<Quality>(() => {
|
||||
|
||||
@@ -54,6 +54,11 @@ export const updateYtDlp = () => invoke<string>("update_yt_dlp");
|
||||
export const listSubtitles = (videoId: string) =>
|
||||
invoke<Array<[string, string]>>("list_subtitles", { videoId });
|
||||
|
||||
/** Subtitles as (language, WebVTT text) — fetched and cached when there are
|
||||
* none beside the video. Streaming has no other source. */
|
||||
export const fetchSubtitles = (videoId: string, lang: string) =>
|
||||
invoke<Array<[string, string]>>("fetch_subtitles", { videoId, lang });
|
||||
|
||||
export const savePlayback = (videoId: string, position: number, duration: number) =>
|
||||
invoke<void>("save_playback", { videoId, position, duration });
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -38,6 +38,8 @@ interface Props {
|
||||
subLang: string;
|
||||
/** Persists a choice made here, so the next video matches. */
|
||||
onSubLang: (l: string) => void;
|
||||
/** Captions are being fetched, so the menu is not empty for lack of any. */
|
||||
subsLoading?: boolean;
|
||||
}
|
||||
|
||||
const SKIP_S = 10;
|
||||
@@ -64,7 +66,7 @@ const btn =
|
||||
* bottom, so the native ones are switched off entirely.
|
||||
*/
|
||||
export default function PlayerControls({
|
||||
videoRef, stageRef, onActivity, subLang, onSubLang,
|
||||
videoRef, stageRef, onActivity, subLang, onSubLang, subsLoading,
|
||||
}: Props) {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [time, setTime] = useState(0);
|
||||
@@ -331,7 +333,7 @@ export default function PlayerControls({
|
||||
}}
|
||||
/>
|
||||
|
||||
{(audio.length > 1 || subs.length > 0) && (
|
||||
{(audio.length > 1 || subs.length > 0 || subsLoading) && (
|
||||
<div ref={menuRef} className="relative shrink-0">
|
||||
<button
|
||||
onClick={() => { setMenu((m) => !m); onActivity?.(); }}
|
||||
@@ -370,6 +372,15 @@ export default function PlayerControls({
|
||||
</>
|
||||
)}
|
||||
|
||||
{subs.length === 0 && subsLoading && (
|
||||
<>
|
||||
<div className="mt-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||
Subtitles
|
||||
</div>
|
||||
<div className="px-2 py-1.5 text-[12px] text-slate-400">Fetching…</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{subs.length > 0 && (
|
||||
<>
|
||||
<div className="mt-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||
|
||||
@@ -276,9 +276,12 @@ export default function Settings({
|
||||
</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.
|
||||
Shown whenever a video has subtitles in this language, including YouTube's
|
||||
auto-generated ones — on most videos those are the only ones there are.
|
||||
Downloads keep them beside the file for offline use; streams fetch them
|
||||
separately, because YouTube's live manifest carries no subtitles at all.
|
||||
<b> None means no subtitles anywhere.</b> You can still switch tracks from
|
||||
the player.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SUB_LANGS } from "../types";
|
||||
|
||||
export function relativeTime(unixSeconds: number): string {
|
||||
if (!unixSeconds) return "";
|
||||
const diff = Date.now() / 1000 - unixSeconds;
|
||||
@@ -51,3 +53,14 @@ export function clockDuration(seconds: number | null): string {
|
||||
const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
|
||||
return `${h > 0 ? `${h}:` : ""}${mm}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A readable name for a subtitle track. YouTube's "-orig" variant is the
|
||||
* captions in the video's own language, as opposed to a machine translation.
|
||||
*/
|
||||
export function subtitleLabel(code: string): string {
|
||||
const base = code.replace(/-orig$/, "");
|
||||
const named = SUB_LANGS.find((l) => l.value === base.toLowerCase());
|
||||
const name = named?.label ?? base.toUpperCase();
|
||||
return code.endsWith("-orig") ? `${name} (original)` : name;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,9 @@ export const BULK_LIMITS: Array<{ value: number; label: string }> = [
|
||||
export const DEFAULT_BULK_LIMIT = 25;
|
||||
|
||||
/** Preferred subtitle language: shown when available, and downloaded. */
|
||||
/** On by default: "None" means no captions anywhere, which is rarely wanted. */
|
||||
export const DEFAULT_SUB_LANG = "en";
|
||||
|
||||
export const SUB_LANGS: Array<{ value: string; label: string }> = [
|
||||
{ value: "off", label: "None" },
|
||||
{ value: "en", label: "English" },
|
||||
|
||||
Reference in New Issue
Block a user