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:
vincent
2026-08-29 17:45:19 +02:00
parent 8dade59bb4
commit ca93cc65ca
6 changed files with 175 additions and 25 deletions
+49 -15
View File
@@ -3,7 +3,7 @@ import {
embeddedSubtitles, fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream,
savePlayback,
} 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 PlayerControls from "./PlayerControls";
import { Badge, BTN, Spinner } from "./ui";
@@ -23,6 +23,8 @@ interface Props {
maxHeight: number | null;
/** Preferred subtitle language, or "off". */
subLang: string;
subStyle: SubStyle;
onSubStyle: (s: SubStyle) => void;
/** Persists a subtitle choice made from the transport bar. */
onSubLang: (l: string) => void;
/** 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
* 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({
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
maxHeight, subLang, onSubLang, index, total, titleBarInset,
maxHeight, subLang, onSubLang, subStyle, onSubStyle, index, total, titleBarInset,
}: Props) {
const streaming = 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
* file:// or 127.0.0.1 track would not.
*/
const [rawTracks, setRawTracks] = useState<Array<[string, string]>>([]);
const [tracks, setTracks] = useState<Array<[string, string]>>([]);
const [fetchingSubs, setFetchingSubs] = useState(false);
// The language to fetch, which is NOT the preference: turning subtitles on
@@ -140,17 +157,9 @@ export default function Player({
useEffect(() => {
let cancelled = false;
const blobs: string[] = [];
setTracks([]);
setRawTracks([]);
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 () => {
if (path) {
// 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(() => []);
if (cancelled) return;
if (inside.length > 0) {
setTracks(asBlobs(inside));
setRawTracks(inside);
return;
}
// 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(() => []);
if (cancelled) return;
if (local.length > 0) {
setTracks(local.map(([lang, file]) => [lang, fileUrl(file)]));
setRawTracks(local);
return;
}
}
@@ -177,7 +186,7 @@ export default function Player({
try {
const list = await fetchSubtitles(item.id, wantLang);
if (cancelled) return;
setTracks(asBlobs(list));
setRawTracks(list);
} catch {
/* a video with no captions in this language is an ordinary outcome */
} finally {
@@ -188,10 +197,28 @@ export default function Player({
return () => {
cancelled = true;
for (const u of blobs) URL.revokeObjectURL(u);
};
}, [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.
const [chromeVisible, setChromeVisible] = useState(true);
const hideTimer = useRef<number | undefined>(undefined);
@@ -357,6 +384,11 @@ export default function Player({
)}
</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
videos are both letterboxed to the pane instead of overflowing it. */}
<div
@@ -453,6 +485,8 @@ export default function Player({
onActivity={showChrome}
subLang={subLang}
onSubLang={onSubLang}
subStyle={subStyle}
onSubStyle={onSubStyle}
subsLoading={fetchingSubs}
/>
</div>