feat: subtitle size, face and placement, set from the player
One fixed size cannot suit everyone — the default was too big, then too small. The subtitle menu in the transport bar now carries an Appearance section: Size (S/M/L/XL), Font (Sans/Serif/Mono) and Place (Bottom/Raised/Top). Choices apply at once and are remembered across videos and launches, like every other preference. Size and face are written into a style element rather than the stylesheet: ::cue takes no custom properties, so the values have to be literal. Placement is not a CSS property at all — WebVTT carries position on the cue — so it is rewritten into the cues themselves and the track re-cut. Every source is text now, including the sidecars beside older downloads, so all three can be placed the same way. The default lands at 85%, between WebKit's own size and the 58% that replaced it. Re-cutting a track exposed a bug in the guard that applies the subtitle preference: it keyed on how many text tracks there were, and re-cutting one leaves that unchanged, so the fresh track would have stayed disabled and the subtitles would have vanished on any change of Place. It keys on the tracks themselves.
This commit is contained in:
@@ -1366,7 +1366,10 @@ pub async fn list_subtitles(
|
|||||||
.next()
|
.next()
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
out.push((lang, path.to_string_lossy().to_string()));
|
let Ok(text) = tokio::fs::read_to_string(&path).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
out.push((lang, tidy_vtt(&text)));
|
||||||
}
|
}
|
||||||
out.sort();
|
out.sort();
|
||||||
Ok(out)
|
Ok(out)
|
||||||
|
|||||||
+17
-3
@@ -17,9 +17,9 @@ 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, DEFAULT_SUB_LANG,
|
BULK_LIMITS, DEFAULT_BULK_LIMIT, DEFAULT_SUB_LANG, DEFAULT_SUB_STYLE,
|
||||||
QUALITIES, STREAM_QUALITIES, SUB_LANGS,
|
QUALITIES, STREAM_QUALITIES, SUB_LANGS,
|
||||||
type FeedFilter, type FeedItem, type Quality, type RefreshProgress,
|
type FeedFilter, type FeedItem, type Quality, type RefreshProgress, type SubStyle,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const TOAST_MS = 2400;
|
const TOAST_MS = 2400;
|
||||||
@@ -105,6 +105,17 @@ export default function App() {
|
|||||||
return DEFAULT_BULK_LIMIT;
|
return DEFAULT_BULK_LIMIT;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// How subtitles look, chosen from the player's own subtitle menu.
|
||||||
|
const [subStyle, setSubStyle] = useState<SubStyle>(() => {
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(localStorage.getItem("flighttube.subStyle") || "null");
|
||||||
|
return stored && typeof stored === "object"
|
||||||
|
? { ...DEFAULT_SUB_STYLE, ...stored }
|
||||||
|
: DEFAULT_SUB_STYLE;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_SUB_STYLE;
|
||||||
|
}
|
||||||
|
});
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [refreshProgress, setRefreshProgress] = useState<RefreshProgress | null>(null);
|
const [refreshProgress, setRefreshProgress] = useState<RefreshProgress | null>(null);
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
@@ -139,6 +150,7 @@ export default function App() {
|
|||||||
localStorage.setItem("flighttube.bulkLimit", String(bulkLimit));
|
localStorage.setItem("flighttube.bulkLimit", String(bulkLimit));
|
||||||
localStorage.setItem("flighttube.streamQuality", streamQuality);
|
localStorage.setItem("flighttube.streamQuality", streamQuality);
|
||||||
localStorage.setItem("flighttube.subLang", subLang);
|
localStorage.setItem("flighttube.subLang", subLang);
|
||||||
|
localStorage.setItem("flighttube.subStyle", JSON.stringify(subStyle));
|
||||||
localStorage.setItem("flighttube.browser", browser);
|
localStorage.setItem("flighttube.browser", browser);
|
||||||
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
|
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
|
||||||
localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
|
localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
|
||||||
@@ -146,7 +158,7 @@ export default function App() {
|
|||||||
} catch {
|
} catch {
|
||||||
/* storage blocked */
|
/* storage blocked */
|
||||||
}
|
}
|
||||||
}, [view, quality, bulkLimit, streamQuality, subLang, browser, downloadedOnly,
|
}, [view, quality, bulkLimit, streamQuality, subLang, subStyle, browser, downloadedOnly,
|
||||||
hideShorts, sidebarHidden]);
|
hideShorts, sidebarHidden]);
|
||||||
|
|
||||||
// The language a download embeds. Like the player's fetch, this does not
|
// The language a download embeds. Like the player's fetch, this does not
|
||||||
@@ -564,6 +576,8 @@ export default function App() {
|
|||||||
maxHeight={streamQuality === "best" ? null : Number(streamQuality)}
|
maxHeight={streamQuality === "best" ? null : Number(streamQuality)}
|
||||||
subLang={subLang}
|
subLang={subLang}
|
||||||
onSubLang={setSubLang}
|
onSubLang={setSubLang}
|
||||||
|
subStyle={subStyle}
|
||||||
|
onSubStyle={setSubStyle}
|
||||||
titleBarInset={titleBarInset}
|
titleBarInset={titleBarInset}
|
||||||
onPrev={
|
onPrev={
|
||||||
stepFrom(playingIndex, -1) != null
|
stepFrom(playingIndex, -1) != null
|
||||||
|
|||||||
+49
-15
@@ -3,7 +3,7 @@ import {
|
|||||||
embeddedSubtitles, fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream,
|
embeddedSubtitles, fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream,
|
||||||
savePlayback,
|
savePlayback,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { DEFAULT_SUB_LANG, type FeedItem } from "../types";
|
import { DEFAULT_SUB_LANG, SUB_FONTS, SUB_PLACES, type FeedItem, type SubStyle } from "../types";
|
||||||
import { compactViews, relativeTime, subtitleLabel } 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";
|
||||||
@@ -23,6 +23,8 @@ interface Props {
|
|||||||
maxHeight: number | null;
|
maxHeight: number | null;
|
||||||
/** Preferred subtitle language, or "off". */
|
/** Preferred subtitle language, or "off". */
|
||||||
subLang: string;
|
subLang: string;
|
||||||
|
subStyle: SubStyle;
|
||||||
|
onSubStyle: (s: SubStyle) => void;
|
||||||
/** Persists a subtitle choice made from the transport bar. */
|
/** Persists a subtitle choice made from the transport bar. */
|
||||||
onSubLang: (l: string) => void;
|
onSubLang: (l: string) => void;
|
||||||
/** Position in the current feed, for the "3 of 180" readout. */
|
/** Position in the current feed, for the "3 of 180" readout. */
|
||||||
@@ -102,9 +104,23 @@ const RESUME_EDGE_S = 5;
|
|||||||
* so watching still happens here rather than in a browser. The iframe embed
|
* so watching still happens here rather than in a browser. The iframe embed
|
||||||
* cannot be used: it rejects a `tauri://` origin with "Error 153".
|
* cannot be used: it rejects a `tauri://` origin with "Error 153".
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Rewrites every cue's settings to one placement.
|
||||||
|
*
|
||||||
|
* WebVTT carries position on the cue itself, so this is the only way to move
|
||||||
|
* subtitles: no CSS property places them. Existing settings are dropped rather
|
||||||
|
* than merged — YouTube's own are exactly what needs overriding.
|
||||||
|
*/
|
||||||
|
function placeCues(vtt: string, line: number | null): string {
|
||||||
|
return vtt.replace(
|
||||||
|
/^(\s*[\d:.]+\s+-->\s+[\d:.]+)(.*)$/gm,
|
||||||
|
(_m, times: string) => (line == null ? times : `${times} line:${line}%`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Player({
|
export default function Player({
|
||||||
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
|
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
|
||||||
maxHeight, subLang, onSubLang, index, total, titleBarInset,
|
maxHeight, subLang, onSubLang, subStyle, onSubStyle, index, total, titleBarInset,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const streaming = path === null;
|
const streaming = path === null;
|
||||||
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
|
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
|
||||||
@@ -130,6 +146,7 @@ export default function Player({
|
|||||||
* Fetched cues become blob URLs, which share the document's origin — a
|
* Fetched cues become blob URLs, which share the document's origin — a
|
||||||
* file:// or 127.0.0.1 track would not.
|
* file:// or 127.0.0.1 track would not.
|
||||||
*/
|
*/
|
||||||
|
const [rawTracks, setRawTracks] = useState<Array<[string, string]>>([]);
|
||||||
const [tracks, setTracks] = useState<Array<[string, string]>>([]);
|
const [tracks, setTracks] = useState<Array<[string, string]>>([]);
|
||||||
const [fetchingSubs, setFetchingSubs] = useState(false);
|
const [fetchingSubs, setFetchingSubs] = useState(false);
|
||||||
// The language to fetch, which is NOT the preference: turning subtitles on
|
// The language to fetch, which is NOT the preference: turning subtitles on
|
||||||
@@ -140,17 +157,9 @@ export default function Player({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const blobs: string[] = [];
|
setRawTracks([]);
|
||||||
setTracks([]);
|
|
||||||
setFetchingSubs(false);
|
setFetchingSubs(false);
|
||||||
|
|
||||||
const asBlobs = (list: Array<[string, string]>) =>
|
|
||||||
list.map(([lang, text]) => {
|
|
||||||
const url = URL.createObjectURL(new Blob([text], { type: "text/vtt" }));
|
|
||||||
blobs.push(url);
|
|
||||||
return [lang, url] as [string, string];
|
|
||||||
});
|
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (path) {
|
if (path) {
|
||||||
// Muxed into the download, which is where they belong — but read out
|
// Muxed into the download, which is where they belong — but read out
|
||||||
@@ -160,7 +169,7 @@ export default function Player({
|
|||||||
const inside = await embeddedSubtitles(item.id).catch(() => []);
|
const inside = await embeddedSubtitles(item.id).catch(() => []);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (inside.length > 0) {
|
if (inside.length > 0) {
|
||||||
setTracks(asBlobs(inside));
|
setRawTracks(inside);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Downloads from before subtitles were embedded kept them beside the
|
// Downloads from before subtitles were embedded kept them beside the
|
||||||
@@ -168,7 +177,7 @@ export default function Player({
|
|||||||
const local = await listSubtitles(item.id).catch(() => []);
|
const local = await listSubtitles(item.id).catch(() => []);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (local.length > 0) {
|
if (local.length > 0) {
|
||||||
setTracks(local.map(([lang, file]) => [lang, fileUrl(file)]));
|
setRawTracks(local);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,7 +186,7 @@ export default function Player({
|
|||||||
try {
|
try {
|
||||||
const list = await fetchSubtitles(item.id, wantLang);
|
const list = await fetchSubtitles(item.id, wantLang);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setTracks(asBlobs(list));
|
setRawTracks(list);
|
||||||
} catch {
|
} catch {
|
||||||
/* a video with no captions in this language is an ordinary outcome */
|
/* a video with no captions in this language is an ordinary outcome */
|
||||||
} finally {
|
} finally {
|
||||||
@@ -188,10 +197,28 @@ export default function Player({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
for (const u of blobs) URL.revokeObjectURL(u);
|
|
||||||
};
|
};
|
||||||
}, [item.id, path, wantLang]);
|
}, [item.id, path, wantLang]);
|
||||||
|
|
||||||
|
// Placement is a WebVTT cue setting, not something CSS can reach, so it is
|
||||||
|
// written into the cues themselves. Re-cut whenever the choice changes.
|
||||||
|
useEffect(() => {
|
||||||
|
const line = SUB_PLACES.find((p) => p.value === subStyle.place)?.line ?? null;
|
||||||
|
const urls: string[] = [];
|
||||||
|
setTracks(
|
||||||
|
rawTracks.map(([lang, text]) => {
|
||||||
|
const url = URL.createObjectURL(
|
||||||
|
new Blob([placeCues(text, line)], { type: "text/vtt" }),
|
||||||
|
);
|
||||||
|
urls.push(url);
|
||||||
|
return [lang, url] as [string, string];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
for (const u of urls) URL.revokeObjectURL(u);
|
||||||
|
};
|
||||||
|
}, [rawTracks, subStyle.place]);
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -357,6 +384,11 @@ export default function Player({
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<style>{`video::cue {
|
||||||
|
font-size: ${subStyle.size}%;
|
||||||
|
font-family: ${SUB_FONTS.find((f) => f.value === subStyle.font)?.stack ?? "sans-serif"};
|
||||||
|
}`}</style>
|
||||||
|
|
||||||
{/* Absolute fill + object-contain, so portrait Shorts and landscape
|
{/* Absolute fill + object-contain, so portrait Shorts and landscape
|
||||||
videos are both letterboxed to the pane instead of overflowing it. */}
|
videos are both letterboxed to the pane instead of overflowing it. */}
|
||||||
<div
|
<div
|
||||||
@@ -453,6 +485,8 @@ export default function Player({
|
|||||||
onActivity={showChrome}
|
onActivity={showChrome}
|
||||||
subLang={subLang}
|
subLang={subLang}
|
||||||
onSubLang={onSubLang}
|
onSubLang={onSubLang}
|
||||||
|
subStyle={subStyle}
|
||||||
|
onSubStyle={onSubStyle}
|
||||||
subsLoading={fetchingSubs}
|
subsLoading={fetchingSubs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { subtitleLabel } from "./format";
|
import { subtitleLabel } from "./format";
|
||||||
|
import { SUB_FONTS, SUB_PLACES, SUB_SIZES, type SubStyle } from "../types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only the tracks this app attached.
|
* Only the tracks this app attached.
|
||||||
@@ -29,10 +30,45 @@ 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;
|
||||||
|
subStyle: SubStyle;
|
||||||
|
onSubStyle: (s: SubStyle) => void;
|
||||||
/** Captions are being fetched, so the menu is not empty for lack of any. */
|
/** Captions are being fetched, so the menu is not empty for lack of any. */
|
||||||
subsLoading?: boolean;
|
subsLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One line of the appearance section: a label and a row of small choices. */
|
||||||
|
function StyleRow<T extends string | number>({
|
||||||
|
label, value, options, onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
options: Array<{ value: T; label: string }>;
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1">
|
||||||
|
<span className="w-10 shrink-0 text-[11px] text-slate-400">{label}</span>
|
||||||
|
<div className="flex min-w-0 flex-1 gap-1">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={String(o.value)}
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
className={
|
||||||
|
"min-w-0 flex-1 cursor-pointer truncate rounded px-1 py-1 text-[11px] " +
|
||||||
|
"transition-colors " +
|
||||||
|
(o.value === value
|
||||||
|
? "bg-sky-500 text-white"
|
||||||
|
: "text-slate-300 hover:bg-slate-800")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const SKIP_S = 10;
|
const SKIP_S = 10;
|
||||||
|
|
||||||
function clock(seconds: number): string {
|
function clock(seconds: number): string {
|
||||||
@@ -57,7 +93,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, subsLoading,
|
videoRef, stageRef, onActivity, subLang, onSubLang, subStyle, onSubStyle, subsLoading,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [time, setTime] = useState(0);
|
const [time, setTime] = useState(0);
|
||||||
@@ -88,7 +124,12 @@ export default function PlayerControls({
|
|||||||
applied.current = "";
|
applied.current = "";
|
||||||
const applyPreference = () => {
|
const applyPreference = () => {
|
||||||
const tracks = Array.from(v.textTracks);
|
const tracks = Array.from(v.textTracks);
|
||||||
const key = `${v.currentSrc}|${tracks.length}`;
|
// Keyed on the tracks themselves, not their number. Changing where
|
||||||
|
// subtitles sit re-cuts the same one track, and a count would not notice
|
||||||
|
// — leaving the fresh track disabled and the subtitles gone.
|
||||||
|
const key = `${v.currentSrc}|${Array.from(v.querySelectorAll("track"))
|
||||||
|
.map((el) => el.src)
|
||||||
|
.join("|")}`;
|
||||||
if (key === applied.current) return;
|
if (key === applied.current) return;
|
||||||
applied.current = key;
|
applied.current = key;
|
||||||
const wanted =
|
const wanted =
|
||||||
@@ -332,7 +373,7 @@ export default function PlayerControls({
|
|||||||
|
|
||||||
{menu && (
|
{menu && (
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-full right-0 mb-2 max-h-72 w-60 overflow-y-auto rounded-lg
|
className="absolute bottom-full right-0 mb-2 max-h-80 w-64 overflow-y-auto rounded-lg
|
||||||
border border-slate-700 bg-slate-900/95 p-1 shadow-2xl backdrop-blur"
|
border border-slate-700 bg-slate-900/95 p-1 shadow-2xl backdrop-blur"
|
||||||
>
|
>
|
||||||
{subs.length === 0 && subsLoading && (
|
{subs.length === 0 && subsLoading && (
|
||||||
@@ -384,6 +425,32 @@ 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">
|
||||||
|
Appearance
|
||||||
|
</div>
|
||||||
|
<StyleRow
|
||||||
|
label="Size"
|
||||||
|
value={subStyle.size}
|
||||||
|
options={SUB_SIZES}
|
||||||
|
onChange={(size) => onSubStyle({ ...subStyle, size })}
|
||||||
|
/>
|
||||||
|
<StyleRow
|
||||||
|
label="Font"
|
||||||
|
value={subStyle.font}
|
||||||
|
options={SUB_FONTS.map((f) => ({ value: f.value, label: f.label }))}
|
||||||
|
onChange={(font) => onSubStyle({ ...subStyle, font })}
|
||||||
|
/>
|
||||||
|
<StyleRow
|
||||||
|
label="Place"
|
||||||
|
value={subStyle.place}
|
||||||
|
options={SUB_PLACES.map((p) => ({ value: p.value, label: p.label }))}
|
||||||
|
onChange={(place) => onSubStyle({ ...subStyle, place })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-3
@@ -35,10 +35,9 @@
|
|||||||
button:focus, button:focus-visible,
|
button:focus, button:focus-visible,
|
||||||
[contenteditable]:focus { outline: none !important; }
|
[contenteditable]:focus { outline: none !important; }
|
||||||
|
|
||||||
/* Subtitles. WebKit's default is sized for a television across a room and
|
/* Subtitles. Size and face come from the player's subtitle menu, written
|
||||||
boxed in solid black; this is the same thing said more quietly. */
|
into a style element there — ::cue takes no custom properties. */
|
||||||
video::cue {
|
video::cue {
|
||||||
font-size: 58%;
|
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
background-color: rgb(2 6 23 / 0.55);
|
background-color: rgb(2 6 23 / 0.55);
|
||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
|
|||||||
@@ -113,6 +113,39 @@ export const STREAM_QUALITIES: Array<{ value: Quality; label: string }> = [
|
|||||||
{ value: "480", label: "480p" },
|
{ value: "480", label: "480p" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** How subtitles look, set from the player's subtitle menu. */
|
||||||
|
export interface SubStyle {
|
||||||
|
/** Percentage of the player's default cue size. */
|
||||||
|
size: number;
|
||||||
|
font: "sans" | "serif" | "mono";
|
||||||
|
place: "bottom" | "raised" | "top";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SUB_STYLE: SubStyle = { size: 85, font: "sans", place: "bottom" };
|
||||||
|
|
||||||
|
export const SUB_SIZES: Array<{ value: number; label: string }> = [
|
||||||
|
{ value: 65, label: "S" },
|
||||||
|
{ value: 85, label: "M" },
|
||||||
|
{ value: 110, label: "L" },
|
||||||
|
{ value: 145, label: "XL" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SUB_FONTS: Array<{ value: SubStyle["font"]; label: string; stack: string }> = [
|
||||||
|
{ value: "sans", label: "Sans", stack: "system-ui, -apple-system, Helvetica, sans-serif" },
|
||||||
|
{ value: "serif", label: "Serif", stack: "Georgia, 'Times New Roman', serif" },
|
||||||
|
{ value: "mono", label: "Mono", stack: "ui-monospace, SFMono-Regular, Menlo, monospace" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the cues sit, as a WebVTT `line` percentage down the picture.
|
||||||
|
* Null leaves the player's own placement, which is the bottom.
|
||||||
|
*/
|
||||||
|
export const SUB_PLACES: Array<{ value: SubStyle["place"]; label: string; line: number | null }> = [
|
||||||
|
{ value: "bottom", label: "Bottom", line: null },
|
||||||
|
{ value: "raised", label: "Raised", line: 78 },
|
||||||
|
{ value: "top", label: "Top", line: 8 },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How many videos one "Download all" may queue. All subscriptions can be
|
* How many videos one "Download all" may queue. All subscriptions can be
|
||||||
* hundreds of videos, which is not something to set going by accident.
|
* hundreds of videos, which is not something to set going by accident.
|
||||||
|
|||||||
Reference in New Issue
Block a user