diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 70f1df4..ec6defc 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1366,7 +1366,10 @@ pub async fn list_subtitles( .next() .unwrap_or("") .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(); Ok(out) diff --git a/src/App.tsx b/src/App.tsx index c85eb15..4c26611 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,9 +17,9 @@ import { useDownloads } from "./hooks/useDownloads"; import { useFeed } from "./hooks/useFeed"; import { useWindowFullscreen } from "./hooks/useWindowFullscreen"; 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, - type FeedFilter, type FeedItem, type Quality, type RefreshProgress, + type FeedFilter, type FeedItem, type Quality, type RefreshProgress, type SubStyle, } from "./types"; const TOAST_MS = 2400; @@ -105,6 +105,17 @@ export default function App() { return DEFAULT_BULK_LIMIT; } }); + // How subtitles look, chosen from the player's own subtitle menu. + const [subStyle, setSubStyle] = useState(() => { + 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 [refreshProgress, setRefreshProgress] = useState(null); const [toast, setToast] = useState(null); @@ -139,6 +150,7 @@ export default function App() { localStorage.setItem("flighttube.bulkLimit", String(bulkLimit)); localStorage.setItem("flighttube.streamQuality", streamQuality); localStorage.setItem("flighttube.subLang", subLang); + localStorage.setItem("flighttube.subStyle", JSON.stringify(subStyle)); localStorage.setItem("flighttube.browser", browser); localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0"); localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0"); @@ -146,7 +158,7 @@ export default function App() { } catch { /* storage blocked */ } - }, [view, quality, bulkLimit, streamQuality, subLang, browser, downloadedOnly, + }, [view, quality, bulkLimit, streamQuality, subLang, subStyle, browser, downloadedOnly, hideShorts, sidebarHidden]); // 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)} subLang={subLang} onSubLang={setSubLang} + subStyle={subStyle} + onSubStyle={setSubStyle} titleBarInset={titleBarInset} onPrev={ stepFrom(playingIndex, -1) != null diff --git a/src/components/Player.tsx b/src/components/Player.tsx index f0891b0..6ccea0e 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -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(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>([]); const [tracks, setTracks] = useState>([]); 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(undefined); @@ -357,6 +384,11 @@ export default function Player({ )} + + {/* Absolute fill + object-contain, so portrait Shorts and landscape videos are both letterboxed to the pane instead of overflowing it. */}
diff --git a/src/components/PlayerControls.tsx b/src/components/PlayerControls.tsx index c17b2da..45108e4 100644 --- a/src/components/PlayerControls.tsx +++ b/src/components/PlayerControls.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { subtitleLabel } from "./format"; +import { SUB_FONTS, SUB_PLACES, SUB_SIZES, type SubStyle } from "../types"; /** * Only the tracks this app attached. @@ -29,10 +30,45 @@ interface Props { subLang: string; /** Persists a choice made here, so the next video matches. */ onSubLang: (l: string) => void; + subStyle: SubStyle; + onSubStyle: (s: SubStyle) => void; /** Captions are being fetched, so the menu is not empty for lack of any. */ subsLoading?: boolean; } +/** One line of the appearance section: a label and a row of small choices. */ +function StyleRow({ + label, value, options, onChange, +}: { + label: string; + value: T; + options: Array<{ value: T; label: string }>; + onChange: (v: T) => void; +}) { + return ( +
+ {label} +
+ {options.map((o) => ( + + ))} +
+
+ ); +} + const SKIP_S = 10; function clock(seconds: number): string { @@ -57,7 +93,7 @@ const btn = * bottom, so the native ones are switched off entirely. */ export default function PlayerControls({ - videoRef, stageRef, onActivity, subLang, onSubLang, subsLoading, + videoRef, stageRef, onActivity, subLang, onSubLang, subStyle, onSubStyle, subsLoading, }: Props) { const [playing, setPlaying] = useState(false); const [time, setTime] = useState(0); @@ -88,7 +124,12 @@ export default function PlayerControls({ applied.current = ""; const applyPreference = () => { 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; applied.current = key; const wanted = @@ -332,7 +373,7 @@ export default function PlayerControls({ {menu && (
{subs.length === 0 && subsLoading && ( @@ -384,6 +425,32 @@ export default function PlayerControls({ ))} )} + + {(subs.length > 0 || subsLoading) && ( + <> +
+ Appearance +
+ onSubStyle({ ...subStyle, size })} + /> + ({ value: f.value, label: f.label }))} + onChange={(font) => onSubStyle({ ...subStyle, font })} + /> + ({ value: p.value, label: p.label }))} + onChange={(place) => onSubStyle({ ...subStyle, place })} + /> + + )}
)} diff --git a/src/index.css b/src/index.css index 4f03de0..e3f20cd 100644 --- a/src/index.css +++ b/src/index.css @@ -35,10 +35,9 @@ button:focus, button:focus-visible, [contenteditable]:focus { outline: none !important; } - /* Subtitles. WebKit's default is sized for a television across a room and - boxed in solid black; this is the same thing said more quietly. */ + /* Subtitles. Size and face come from the player's subtitle menu, written + into a style element there — ::cue takes no custom properties. */ video::cue { - font-size: 58%; line-height: 1.35; background-color: rgb(2 6 23 / 0.55); color: #f8fafc; diff --git a/src/types.ts b/src/types.ts index f3beb80..08bf2ea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -113,6 +113,39 @@ export const STREAM_QUALITIES: Array<{ value: Quality; label: string }> = [ { 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 * hundreds of videos, which is not something to set going by accident.