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:
@@ -14,7 +14,7 @@ use crate::thumbs;
|
|||||||
use futures::stream::StreamExt;
|
use futures::stream::StreamExt;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{AppHandle, Emitter, Manager, State};
|
use tauri::{AppHandle, Emitter, Manager, State};
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
@@ -1369,6 +1369,79 @@ pub async fn list_subtitles(
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads the WebVTT files in a directory as (language, text), newest naming
|
||||||
|
/// convention `<id>.<lang>.vtt`. Identical texts are collapsed: YouTube serves
|
||||||
|
/// the same auto-generated captions under both `en` and `en-orig`, and offering
|
||||||
|
/// the same track twice is just noise in the menu.
|
||||||
|
async fn read_vtt_dir(dir: &Path) -> Vec<(String, String)> {
|
||||||
|
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut out: Vec<(String, String)> = Vec::new();
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let Some(stem) = name.strip_suffix(".vtt") else { continue };
|
||||||
|
let Some((_, lang)) = stem.rsplit_once('.') else { continue };
|
||||||
|
let Ok(text) = tokio::fs::read_to_string(entry.path()).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
out.push((lang.to_string(), tidy_vtt(&text)));
|
||||||
|
}
|
||||||
|
// Sorting first makes the survivor of a duplicate the plainer code: "en"
|
||||||
|
// rather than "en-orig".
|
||||||
|
out.sort();
|
||||||
|
let mut seen: Vec<String> = Vec::new();
|
||||||
|
out.retain(|(_, text)| {
|
||||||
|
if seen.iter().any(|t| t == text) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
seen.push(text.clone());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subtitles for a video, as (language, WebVTT text).
|
||||||
|
///
|
||||||
|
/// Streaming has no other source: YouTube's HLS manifest lists a dozen audio
|
||||||
|
/// renditions and no subtitles at all. A download saved before subtitles were
|
||||||
|
/// switched on has none beside it either, and this fills those in without
|
||||||
|
/// fetching the video again. Results are cached per video and language, so
|
||||||
|
/// replaying something costs nothing and works offline.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn fetch_subtitles(
|
||||||
|
video_id: String,
|
||||||
|
lang: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<(String, String)>, String> {
|
||||||
|
let sub_langs = downloader::sub_langs_for(&lang);
|
||||||
|
if sub_langs.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = state.app_data.join("subs").join(&video_id).join(&lang);
|
||||||
|
let cached = read_vtt_dir(&dir).await;
|
||||||
|
if !cached.is_empty() {
|
||||||
|
return Ok(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::fs::create_dir_all(&dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Cannot create subtitle cache: {e}"))?;
|
||||||
|
|
||||||
|
let template = dir.join("%(id)s.%(ext)s").to_string_lossy().to_string();
|
||||||
|
let mut args = downloader::subs_only_args(&video_id, &template, &sub_langs);
|
||||||
|
args.push("--ffmpeg-location".into());
|
||||||
|
args.push(bin("ffmpeg"));
|
||||||
|
|
||||||
|
// A video with no captions in this language is an ordinary outcome, not a
|
||||||
|
// failure worth reporting, so the exit status is not consulted: whatever
|
||||||
|
// landed on disk is the answer.
|
||||||
|
let _ = state.yt_dlp().await.args(&args).output().await;
|
||||||
|
Ok(read_vtt_dir(&dir).await)
|
||||||
|
}
|
||||||
|
|
||||||
/// Removes every download and the files behind them, including subtitles.
|
/// Removes every download and the files behind them, including subtitles.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result<usize, String> {
|
pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result<usize, String> {
|
||||||
@@ -1386,6 +1459,9 @@ pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result<usize, S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Fetched captions live in the cache rather than beside the video, so they
|
||||||
|
// would otherwise survive a wipe.
|
||||||
|
let _ = tokio::fs::remove_dir_all(state.app_data.join("subs")).await;
|
||||||
state.db.lock().await.clear_all_downloads()
|
state.db.lock().await.clear_all_downloads()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,32 @@ pub fn build_args(
|
|||||||
args
|
args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Arguments for fetching only the subtitles of a video.
|
||||||
|
///
|
||||||
|
/// YouTube's HLS manifest carries audio renditions but no subtitles whatsoever,
|
||||||
|
/// so a streamed video has nothing to show unless the captions are fetched
|
||||||
|
/// separately. Auto-generated captions are included: on most videos they are
|
||||||
|
/// the only ones there are.
|
||||||
|
pub fn subs_only_args(video_id: &str, out_template: &str, sub_langs: &str) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"--skip-download".into(),
|
||||||
|
"--no-playlist".into(),
|
||||||
|
"--no-colors".into(),
|
||||||
|
"--ignore-errors".into(),
|
||||||
|
"--write-subs".into(),
|
||||||
|
"--write-auto-subs".into(),
|
||||||
|
"--sub-format".into(),
|
||||||
|
"vtt".into(),
|
||||||
|
"--convert-subs".into(),
|
||||||
|
"vtt".into(),
|
||||||
|
"--sub-langs".into(),
|
||||||
|
sub_langs.to_string(),
|
||||||
|
"-o".into(),
|
||||||
|
out_template.into(),
|
||||||
|
format!("https://www.youtube.com/watch?v={video_id}"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
/// The subtitle languages to request for a preference, or empty for none.
|
/// The subtitle languages to request for a preference, or empty for none.
|
||||||
///
|
///
|
||||||
/// Only the language itself and YouTube's "-orig" variant; anything broader
|
/// Only the language itself and YouTube's "-orig" variant; anything broader
|
||||||
@@ -160,6 +186,29 @@ pub fn parse_final_path(line: &str) -> Option<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subs_only_args_take_auto_generated_captions() {
|
||||||
|
let args = subs_only_args("abc", "/tmp/%(id)s.%(ext)s", "en,en-orig");
|
||||||
|
// Most videos have no hand-written captions at all; without this the
|
||||||
|
// fetch comes back empty.
|
||||||
|
assert!(args.iter().any(|a| a == "--write-auto-subs"));
|
||||||
|
assert!(args.iter().any(|a| a == "--write-subs"));
|
||||||
|
assert!(args.iter().any(|a| a == "--skip-download"));
|
||||||
|
// Named exactly: a wildcard drags in dozens of machine translations.
|
||||||
|
let langs = args.iter().position(|a| a == "--sub-langs").unwrap();
|
||||||
|
assert_eq!(args[langs + 1], "en,en-orig");
|
||||||
|
assert_eq!(args.last().unwrap(), "https://www.youtube.com/watch?v=abc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subs_only_args_are_written_where_asked() {
|
||||||
|
let args = subs_only_args("abc", "/cache/%(id)s.%(ext)s", "nl,nl-orig");
|
||||||
|
let out = args.iter().position(|a| a == "-o").unwrap();
|
||||||
|
assert_eq!(args[out + 1], "/cache/%(id)s.%(ext)s");
|
||||||
|
}
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_complete_line() {
|
fn parses_complete_line() {
|
||||||
let p = parse_progress_line("FTPROG 1048576 10485760 524288.0 18").unwrap();
|
let p = parse_progress_line("FTPROG 1048576 10485760 524288.0 18").unwrap();
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
|
|||||||
commands::delete_download,
|
commands::delete_download,
|
||||||
commands::delete_all_downloads,
|
commands::delete_all_downloads,
|
||||||
commands::list_subtitles,
|
commands::list_subtitles,
|
||||||
|
commands::fetch_subtitles,
|
||||||
commands::fetch_durations,
|
commands::fetch_durations,
|
||||||
commands::list_browsers,
|
commands::list_browsers,
|
||||||
commands::set_cookie_source,
|
commands::set_cookie_source,
|
||||||
|
|||||||
+4
-3
@@ -16,7 +16,8 @@ import { useDownloads } from "./hooks/useDownloads";
|
|||||||
import { useFeed } from "./hooks/useFeed";
|
import { useFeed } from "./hooks/useFeed";
|
||||||
import { useWindowFullscreen } from "./hooks/useWindowFullscreen";
|
import { useWindowFullscreen } from "./hooks/useWindowFullscreen";
|
||||||
import {
|
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,
|
type FeedFilter, type FeedItem, type Quality, type RefreshProgress,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -66,9 +67,9 @@ export default function App() {
|
|||||||
const [subLang, setSubLang] = useState(() => {
|
const [subLang, setSubLang] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem("flighttube.subLang");
|
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 {
|
} catch {
|
||||||
return "off";
|
return DEFAULT_SUB_LANG;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const [streamQuality, setStreamQuality] = useState<Quality>(() => {
|
const [streamQuality, setStreamQuality] = useState<Quality>(() => {
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ export const updateYtDlp = () => invoke<string>("update_yt_dlp");
|
|||||||
export const listSubtitles = (videoId: string) =>
|
export const listSubtitles = (videoId: string) =>
|
||||||
invoke<Array<[string, string]>>("list_subtitles", { videoId });
|
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) =>
|
export const savePlayback = (videoId: string, position: number, duration: number) =>
|
||||||
invoke<void>("save_playback", { videoId, position, duration });
|
invoke<void>("save_playback", { videoId, position, duration });
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 type { FeedItem } from "../types";
|
||||||
import { compactViews, relativeTime } from "./format";
|
import { compactViews, relativeTime, subtitleLabel } from "./format";
|
||||||
import PlayerControls from "./PlayerControls";
|
import PlayerControls from "./PlayerControls";
|
||||||
import { Badge, BTN, Spinner } from "./ui";
|
import { Badge, BTN, Spinner } from "./ui";
|
||||||
|
|
||||||
@@ -127,6 +129,41 @@ export default function Player({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [item.id, path]);
|
}, [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.
|
// Controls and edge arrows fade away while you are just watching.
|
||||||
const [chromeVisible, setChromeVisible] = useState(true);
|
const [chromeVisible, setChromeVisible] = useState(true);
|
||||||
const hideTimer = useRef<number | undefined>(undefined);
|
const hideTimer = useRef<number | undefined>(undefined);
|
||||||
@@ -362,10 +399,19 @@ export default function Player({
|
|||||||
key={file}
|
key={file}
|
||||||
kind="subtitles"
|
kind="subtitles"
|
||||||
srcLang={lang}
|
srcLang={lang}
|
||||||
label={lang}
|
label={subtitleLabel(lang)}
|
||||||
src={fileUrl(file)}
|
src={fileUrl(file)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{fetched.map(([lang, url]) => (
|
||||||
|
<track
|
||||||
|
key={url}
|
||||||
|
kind="subtitles"
|
||||||
|
srcLang={lang}
|
||||||
|
label={subtitleLabel(lang)}
|
||||||
|
src={url}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</video>
|
</video>
|
||||||
) : (
|
) : (
|
||||||
error && (
|
error && (
|
||||||
@@ -394,6 +440,7 @@ export default function Player({
|
|||||||
onActivity={showChrome}
|
onActivity={showChrome}
|
||||||
subLang={subLang}
|
subLang={subLang}
|
||||||
onSubLang={onSubLang}
|
onSubLang={onSubLang}
|
||||||
|
subsLoading={fetchingSubs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ interface Props {
|
|||||||
subLang: string;
|
subLang: string;
|
||||||
/** Persists a choice made here, so the next video matches. */
|
/** Persists a choice made here, so the next video matches. */
|
||||||
onSubLang: (l: string) => void;
|
onSubLang: (l: string) => void;
|
||||||
|
/** Captions are being fetched, so the menu is not empty for lack of any. */
|
||||||
|
subsLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SKIP_S = 10;
|
const SKIP_S = 10;
|
||||||
@@ -64,7 +66,7 @@ const btn =
|
|||||||
* bottom, so the native ones are switched off entirely.
|
* bottom, so the native ones are switched off entirely.
|
||||||
*/
|
*/
|
||||||
export default function PlayerControls({
|
export default function PlayerControls({
|
||||||
videoRef, stageRef, onActivity, subLang, onSubLang,
|
videoRef, stageRef, onActivity, subLang, onSubLang, subsLoading,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [time, setTime] = useState(0);
|
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">
|
<div ref={menuRef} className="relative shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setMenu((m) => !m); onActivity?.(); }}
|
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 && (
|
{subs.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
<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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<p className={`mt-2 ${HELP}`}>
|
<p className={`mt-2 ${HELP}`}>
|
||||||
Shown automatically when a video has subtitles in this language, including
|
Shown whenever a video has subtitles in this language, including YouTube's
|
||||||
YouTube's auto-generated ones, and saved alongside anything you download so
|
auto-generated ones — on most videos those are the only ones there are.
|
||||||
they work offline. You can still switch tracks from the player.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { SUB_LANGS } from "../types";
|
||||||
|
|
||||||
export function relativeTime(unixSeconds: number): string {
|
export function relativeTime(unixSeconds: number): string {
|
||||||
if (!unixSeconds) return "";
|
if (!unixSeconds) return "";
|
||||||
const diff = Date.now() / 1000 - unixSeconds;
|
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);
|
const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
|
||||||
return `${h > 0 ? `${h}:` : ""}${mm}:${String(s).padStart(2, "0")}`;
|
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;
|
export const DEFAULT_BULK_LIMIT = 25;
|
||||||
|
|
||||||
/** Preferred subtitle language: shown when available, and downloaded. */
|
/** 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 }> = [
|
export const SUB_LANGS: Array<{ value: string; label: string }> = [
|
||||||
{ value: "off", label: "None" },
|
{ value: "off", label: "None" },
|
||||||
{ value: "en", label: "English" },
|
{ value: "en", label: "English" },
|
||||||
|
|||||||
Reference in New Issue
Block a user