diff --git a/package.json b/package.json index f3f7b5e2..3d3e46ef 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "flag-icons": "^6.11.1", "fscreen": "^1.2.0", "fuse.js": "^6.4.6", + "graphql-request": "^6.1.0", "hls.js": "^1.0.7", "i18next": "^22.4.5", "i18next-browser-languagedetector": "^7.0.1", @@ -39,6 +40,7 @@ "slugify": "^1.6.6", "subsrt-ts": "^2.1.1", "unpacker": "^1.0.1", + "unzipit": "^1.4.3", "zustand": "^4.3.9" }, "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 694d52bd..6b1e81f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ dependencies: fuse.js: specifier: ^6.4.6 version: 6.6.2 + graphql-request: + specifier: ^6.1.0 + version: 6.1.0(graphql@16.8.1) hls.js: specifier: ^1.0.7 version: 1.4.11 @@ -110,6 +113,9 @@ dependencies: unpacker: specifier: ^1.0.1 version: 1.0.1 + unzipit: + specifier: ^1.4.3 + version: 1.4.3 zustand: specifier: ^4.3.9 version: 4.4.1(@types/react@17.0.65)(immer@10.0.2)(react@17.0.2) @@ -1754,6 +1760,14 @@ packages: resolution: {integrity: sha512-RczHUr0AhRPssREoNdRjLfk2b/id9/DFnbIq18QM8L7E4zNV3XH+WO480EZ46BQHDEsv76YPJ0JbG2Y2i3GfXw==} dev: false + /@graphql-typed-document-node/core@3.2.0(graphql@16.8.1): + resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + graphql: 16.8.1 + dev: false + /@headlessui/react@1.7.17(react-dom@17.0.2)(react@17.0.2): resolution: {integrity: sha512-4am+tzvkqDSSgiwrsEpGWqgGo9dz8qU5M3znCkC4PgkpY4HcCZzEDEvozltGGGHIKl9jbXbZPSH5TWn4sWJdow==} engines: {node: '>=10'} @@ -2988,6 +3002,14 @@ packages: requiresBuild: true dev: false + /cross-fetch@3.1.8: + resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==} + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + dev: false + /cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -3997,6 +4019,23 @@ packages: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} dev: true + /graphql-request@6.1.0(graphql@16.8.1): + resolution: {integrity: sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==} + peerDependencies: + graphql: 14 - 16 + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.8.1) + cross-fetch: 3.1.8 + graphql: 16.8.1 + transitivePeerDependencies: + - encoding + dev: false + + /graphql@16.8.1: + resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + dev: false + /handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -6224,6 +6263,13 @@ packages: resolution: {integrity: sha512-0HTljwp8+JBdITpoHcK1LWi7X9U2BspUmWv78UWZh7NshYhbh1nec8baY/iSbe2OQTZ2bhAtVdnr6/BTD0DKVg==} dev: false + /unzipit@1.4.3: + resolution: {integrity: sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==} + engines: {node: '>=12'} + dependencies: + uzip-module: 1.0.3 + dev: false + /upath@1.2.0: resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} engines: {node: '>=4'} @@ -6265,6 +6311,10 @@ packages: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true + /uzip-module@1.0.3: + resolution: {integrity: sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==} + dev: false + /value-equal@1.0.1: resolution: {integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==} dev: false diff --git a/src/backend/helpers/fetch.ts b/src/backend/helpers/fetch.ts index 16cde080..d798763f 100644 --- a/src/backend/helpers/fetch.ts +++ b/src/backend/helpers/fetch.ts @@ -11,8 +11,8 @@ function getProxyUrl(): string { return url; } -type P = Parameters>; -type R = ReturnType>; +type P = Parameters>; +type R = ReturnType>; const baseFetch = ofetch.create({ retry: 0, @@ -50,6 +50,9 @@ export function proxiedFetch(url: string, ops: P[1] = {}): R { Object.entries(ops?.params ?? {}).forEach(([k, v]) => { parsedUrl.searchParams.set(k, v); }); + Object.entries(ops?.query ?? {}).forEach(([k, v]) => { + parsedUrl.searchParams.set(k, v); + }); return baseFetch(getProxyUrl(), { ...ops, @@ -57,6 +60,7 @@ export function proxiedFetch(url: string, ops: P[1] = {}): R { params: { destination: parsedUrl.toString(), }, + query: {}, }); } diff --git a/src/backend/helpers/subs.ts b/src/backend/helpers/subs.ts index dc5adc06..da88c36b 100644 --- a/src/backend/helpers/subs.ts +++ b/src/backend/helpers/subs.ts @@ -1,92 +1,139 @@ +import { gql, request } from "graphql-request"; +import { unzip } from "unzipit"; + import { proxiedFetch } from "@/backend/helpers/fetch"; -import { testSubData } from "@/backend/helpers/testsub"; +import { languageMap } from "@/setup/iso6391"; import { PlayerMeta } from "@/stores/player/slices/source"; -import { normalizeTitle } from "@/utils/normalizeTitle"; -interface SuggestResult { - name: string; - year: string; - id: number; - kind: "tv" | "movie"; -} +const GQL_API = "https://gqlos.plus-sub.com"; -export interface Subtitle { +const subtitleSearchQuery = gql` + query SubtitleSearch($tmdb_id: String!, $ep: Int, $season: Int) { + subtitleSearch( + tmdb_id: $tmdb_id + language: "" + episode_number: $ep + season_number: $season + ) { + data { + attributes { + language + subtitle_id + ai_translated + auto_translation + ratings + votes + legacy_subtitle_id + } + id + } + } + } +`; + +interface RawSubtitleSearchItem { id: string; - language: string; + attributes: { + language: string; + ai_translated: boolean | null; + auto_translation: null | boolean; + ratings: number; + votes: number | null; + legacy_subtitle_id: string | null; + }; } -const metaTypeToOpenSubs = { - tv: "show", - movie: "movie", -} as const; +export interface SubtitleSearchItem { + id: string; + attributes: { + language: string; + ai_translated: boolean | null; + auto_translation: null | boolean; + ratings: number; + votes: number | null; + legacy_subtitle_id: string; + }; +} -export async function getOpenSubsId(meta: PlayerMeta): Promise { - const req = await proxiedFetch( - `https://www.opensubtitles.org/libs/suggest.php`, +interface SubtitleSearchData { + subtitleSearch: { + data: RawSubtitleSearchItem[]; + }; +} + +export async function searchSubtitles( + meta: PlayerMeta +): Promise { + const data = await request({ + document: subtitleSearchQuery, + url: GQL_API, + variables: { + tmdb_id: meta.tmdbId, + ep: meta.episode?.number, + season: meta.season?.number, + }, + }); + + const sortedByLanguage: Record = {}; + data.subtitleSearch.data.forEach((v) => { + if (!sortedByLanguage[v.attributes.language]) + sortedByLanguage[v.attributes.language] = []; + sortedByLanguage[v.attributes.language].push(v); + }); + + return Object.values(sortedByLanguage).map((langs) => { + const sortedByRating = langs + .filter((v): v is SubtitleSearchItem => !!v.attributes.legacy_subtitle_id) // must have legacy id + .sort((a, b) => b.attributes.ratings - a.attributes.ratings); + return sortedByRating[0]; + }); +} + +export function languageIdToName(langId: string): string | null { + return languageMap[langId]?.nativeName ?? null; +} + +// export async function downloadSrt(subId: string): Promise { +// const downloadScript = await proxiedFetch( +// `https://www.opensubtitles.com/nocache/download/${subId}/subreq.js`, +// { +// query: { +// file_name: "sub", +// locale: "en", // locale is ignored +// np: "true", +// sub_frmt: "srt", +// ext_installed: "false", +// }, +// } +// ); + +// // extract url from script +// // example: https://www.opensubtitles.com/download//subfile/sub.srt +// const downloadUrlRegex = +// /https:\/\/www.opensubtitles.com\/download\/[A-Fa-f0-9]+\/subfile\/sub\.srt/g; +// const matchedUrl = downloadScript.match(downloadUrlRegex); +// if (!matchedUrl) throw new Error("No download found"); +// const downloadUrl = matchedUrl[0]; + +// // download +// const srtRequest = await fetch(downloadUrl); +// const srtData = await srtRequest.text(); +// return srtData; +// } + +export async function downloadSrt(legacySubId: string): Promise { + // TODO there is cloudflare protection so this may not always work. what to do about that? + // language code is hardcoded here, it does nothing + const zipFile = await proxiedFetch( + `https://dl.opensubtitles.org/en/subtitleserve/sub/${legacySubId}`, { - method: "GET", - headers: { - "Alt-Used": "www.opensubtitles.org", - "X-Referer": "https://www.opensubtitles.org/en/search/subs", - }, - query: { - format: "json", - MovieName: meta.title, - }, + responseType: "arrayBuffer", } ); - const foundMatch = req.find((v) => { - const type = metaTypeToOpenSubs[v.kind]; - if (type !== meta.type) return false; - if (+v.year !== meta.releaseYear) return false; - return normalizeTitle(v.name) === normalizeTitle(meta.title); - }); - if (!foundMatch) return null; - return foundMatch.id.toString(); + + const { entries } = await unzip(zipFile); + const srtEntry = Object.values(entries).find((v) => v.name); + if (!srtEntry) throw new Error("No srt file found in zip"); + const srtData = srtEntry.text(); + return srtData; } - -export async function getHighestRatedSubs(id: string): Promise { - // TODO support episodes - const document = await proxiedFetch( - `https://www.opensubtitles.org/en/search/sublanguageid-all/idmovie-${encodeURIComponent( - id - )}/sort-6/asc-0` - ); - const dom = new DOMParser().parseFromString(document, "text/html"); - const table = dom.querySelector("#search_results > tbody"); - if (!table) throw new Error("No result table found"); - const results = [...table.querySelectorAll("tr[id^='name']")].map((v) => { - const subId = v.id.substring(4); // remove "name" from "name" - const languageFlag = v.children[1].querySelector("div[class*='flag']"); - if (!languageFlag) return null; - const languageFlagClasses = languageFlag.classList.toString().split(" "); - const languageCode = languageFlagClasses.filter( - (cssClass) => cssClass === "flag" - )[0]; - - return { - id: subId, - language: languageCode, - }; - }); - - const languages: string[] = []; - const output: Subtitle[] = []; - results.forEach((v) => { - if (!v) return; - if (languages.includes(v.language)) return; // no duplicate languages - output.push(v); - languages.push(v.language); - }); - - return output; -} - -export async function downloadSrt(_subId: string): Promise { - // TODO download, unzip and return srt data - return testSubData.srtData; -} - -/** - * None of this works, CF protected endpoints :( - */ diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index f19e8919..802cb32e 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -44,6 +44,7 @@ export enum Icons { MAIL = "mail", CIRCLE_CHECK = "circle_check", SKIP_EPISODE = "skip_episode", + IOS_SHARE = "ios_share", } export interface IconProps { @@ -95,6 +96,7 @@ const iconList: Record = { mail: ``, circle_check: ``, skip_episode: ``, + ios_share: ``, }; function ChromeCastButton() { diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index 65c6bda9..6c9942df 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -5,6 +5,7 @@ import { OverlayAnchor } from "@/components/overlays/OverlayAnchor"; import { Overlay } from "@/components/overlays/OverlayDisplay"; import { OverlayPage } from "@/components/overlays/OverlayPage"; import { OverlayRouter } from "@/components/overlays/OverlayRouter"; +import { DownloadView } from "@/components/player/atoms/settings/DownloadView"; import { SettingsMenu } from "@/components/player/atoms/settings/SettingsMenu"; import { EmbedSelectionView, @@ -46,11 +47,11 @@ function SettingsOverlay({ id }: { id: string }) { - + - + - + @@ -70,6 +71,11 @@ function SettingsOverlay({ id }: { id: string }) { + + + + + ); diff --git a/src/components/player/atoms/settings/CaptionSettingsView.tsx b/src/components/player/atoms/settings/CaptionSettingsView.tsx index 2818d608..c42abb12 100644 --- a/src/components/player/atoms/settings/CaptionSettingsView.tsx +++ b/src/components/player/atoms/settings/CaptionSettingsView.tsx @@ -1,6 +1,7 @@ import classNames from "classnames"; import { useCallback, useEffect, useRef, useState } from "react"; +import { Toggle } from "@/components/buttons/Toggle"; import { Icon, Icons } from "@/components/Icon"; import { Menu } from "@/components/player/internals/ContextMenu"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; @@ -161,6 +162,8 @@ const colors = ["#ffffff", "#80b1fa", "#e2e535"]; export function CaptionSettingsView({ id }: { id: string }) { const router = useOverlayRouter(id); const styling = useSubtitleStore((s) => s.styling); + const overrideCasing = useSubtitleStore((s) => s.overrideCasing); + const setOverrideCasing = useSubtitleStore((s) => s.setOverrideCasing); const updateStyling = useSubtitleStore((s) => s.updateStyling); return ( @@ -197,6 +200,15 @@ export function CaptionSettingsView({ id }: { id: string }) { ))} +
+ Fix capitalization +
+ setOverrideCasing(!overrideCasing)} + /> +
+
); diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index 182d2fd7..66052962 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -3,8 +3,8 @@ import { useAsync, useAsyncFn } from "react-use"; import { downloadSrt, - getHighestRatedSubs, - getOpenSubsId, + languageIdToName, + searchSubtitles, } from "@/backend/helpers/subs"; import { FlagIcon } from "@/components/FlagIcon"; import { Menu } from "@/components/player/internals/ContextMenu"; @@ -19,11 +19,26 @@ export function CaptionOption(props: { selected?: boolean; onClick?: () => void; }) { + // Country code overrides + const countryOverrides: Record = { + en: "gb", + cs: "cz", + el: "gr", + fa: "ir", + ko: "kr", + he: "il", + ze: "cn", + }; + let countryCode = + (props.countryCode || "")?.split("-").pop()?.toLowerCase() || ""; + if (countryOverrides[countryCode]) + countryCode = countryOverrides[countryCode]; + return ( - - + + {props.children} @@ -31,6 +46,11 @@ export function CaptionOption(props: { ); } +// TODO cache like everything in this view +// TODO make quick settings for caption language +// TODO fix language names, some are unknown +// TODO add search bar for languages +// TODO sort languages by common usage export function CaptionsView({ id }: { id: string }) { const router = useOverlayRouter(id); const setCaption = usePlayerStore((s) => s.setCaption); @@ -40,13 +60,7 @@ export function CaptionsView({ id }: { id: string }) { const req = useAsync(async () => { if (!meta) throw new Error("No meta"); - const subId = await getOpenSubsId(meta); - if (!subId) throw new Error("No sub id found"); - const subs = await getHighestRatedSubs(subId); - return { - subId, - subs, - }; + return searchSubtitles(meta); }, [meta]); const [downloadReq, startDownload] = useAsyncFn( @@ -75,14 +89,16 @@ export function CaptionsView({ id }: { id: string }) { if (req.loading) content =

loading...

; else if (req.error) content =

errored!

; else if (req.value) - content = req.value.subs.map((v) => ( + content = req.value.map((v) => ( startDownload(v.id, v.language)} + countryCode={v.attributes.language} + selected={lang === v.attributes.language} + onClick={() => + startDownload(v.attributes.legacy_subtitle_id, v.attributes.language) + } > - {v.language} + {languageIdToName(v.attributes.language) ?? "unknown"} )); diff --git a/src/components/player/atoms/settings/DownloadView.tsx b/src/components/player/atoms/settings/DownloadView.tsx new file mode 100644 index 00000000..0042824a --- /dev/null +++ b/src/components/player/atoms/settings/DownloadView.tsx @@ -0,0 +1,31 @@ +import { Menu } from "@/components/player/internals/ContextMenu"; +import { useOverlayRouter } from "@/hooks/useOverlayRouter"; + +export function DownloadView({ id }: { id: string }) { + const router = useOverlayRouter(id); + + return ( + <> + router.navigate("/")}> + Download + + +
+ + Downloads are taken directly from the provider. movie-web does not + have control over how the downloads are provided. + + + To download on iOS, click Share, + then Save to File and then as after + you click the button. + + + To download on Android or PC, click or tap and hold on the video, + then select save as. + +
+
+ + ); +} diff --git a/src/components/player/atoms/settings/SettingsMenu.tsx b/src/components/player/atoms/settings/SettingsMenu.tsx index 5f3d8a84..be1a8cca 100644 --- a/src/components/player/atoms/settings/SettingsMenu.tsx +++ b/src/components/player/atoms/settings/SettingsMenu.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; +import { languageIdToName } from "@/backend/helpers/subs"; import { Toggle } from "@/components/buttons/Toggle"; import { Icon, Icons } from "@/components/Icon"; import { Menu } from "@/components/player/internals/ContextMenu"; @@ -34,6 +35,10 @@ export function SettingsMenu({ id }: { id: string }) { } } + const selectedLanguagePretty = selectedCaptionLanguage + ? languageIdToName(selectedCaptionLanguage) ?? "unknown" + : undefined; + return ( Video settings @@ -52,6 +57,7 @@ export function SettingsMenu({ id }: { id: string }) { router.navigate("/download")} rightSide={} > Download @@ -72,7 +78,7 @@ export function SettingsMenu({ id }: { id: string }) { router.navigate("/captions")} - rightText={selectedCaptionLanguage} + rightText={selectedLanguagePretty} > Caption settings diff --git a/src/components/player/base/SubtitleView.tsx b/src/components/player/base/SubtitleView.tsx index 81463326..9737a67e 100644 --- a/src/components/player/base/SubtitleView.tsx +++ b/src/components/player/base/SubtitleView.tsx @@ -14,11 +14,27 @@ import { SubtitleStyling, useSubtitleStore } from "@/stores/subtitles"; export function CaptionCue({ text, styling, + overrideCasing, }: { text?: string; styling: SubtitleStyling; + overrideCasing: boolean; }) { - const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "
"); + const wordOverrides: Record = { + i: "I", + }; + + let textToUse = text; + if (overrideCasing && text) { + textToUse = text.slice(0, 1) + text.slice(1).toLowerCase(); + } + + const textWithNewlines = (textToUse || "") + .split(" ") + .map((word) => wordOverrides[word] ?? word) + .join(" ") + .replaceAll(/ i'/g, " I'") + .replaceAll(/\r?\n/g, "
"); // https://www.w3.org/TR/webvtt1/#dom-construction-rules // added a
for newlines @@ -53,6 +69,7 @@ export function SubtitleRenderer() { const videoTime = usePlayerStore((s) => s.progress.time); const srtData = usePlayerStore((s) => s.caption.selected?.srtData); const styling = useSubtitleStore((s) => s.styling); + const overrideCasing = useSubtitleStore((s) => s.overrideCasing); const parsedCaptions = useMemo( () => (srtData ? parseSubtitles(srtData) : []), @@ -74,6 +91,7 @@ export function SubtitleRenderer() { key={makeQueId(i, start, end)} text={content} styling={styling} + overrideCasing={overrideCasing} /> ))} diff --git a/src/components/player/internals/ContextMenu/Misc.tsx b/src/components/player/internals/ContextMenu/Misc.tsx index 80f756ab..f035326e 100644 --- a/src/components/player/internals/ContextMenu/Misc.tsx +++ b/src/components/player/internals/ContextMenu/Misc.tsx @@ -49,6 +49,14 @@ export function FieldTitle(props: { children: React.ReactNode }) { return

{props.children}

; } +export function Paragraph(props: { children: React.ReactNode }) { + return

{props.children}

; +} + +export function Highlight(props: { children: React.ReactNode }) { + return {props.children}; +} + export function TextDisplay(props: { children: React.ReactNode; title?: string; diff --git a/src/setup/iso6391.ts b/src/setup/iso6391.ts index 3499155b..0ea4e817 100644 --- a/src/setup/iso6391.ts +++ b/src/setup/iso6391.ts @@ -1331,3 +1331,8 @@ export const captionLanguages: CaptionLanguageOption[] = [ nativeName: "IsiZulu", }, ]; + +export const languageMap: Record = {}; +captionLanguages.forEach((v) => { + languageMap[v.id] = v; +}); diff --git a/src/stores/subtitles/index.ts b/src/stores/subtitles/index.ts index 079799af..bf22b071 100644 --- a/src/stores/subtitles/index.ts +++ b/src/stores/subtitles/index.ts @@ -23,8 +23,10 @@ export interface SubtitleStore { enabled: boolean; lastSelectedLanguage: string | null; styling: SubtitleStyling; + overrideCasing: boolean; updateStyling(newStyling: Partial): void; setLanguage(language: string | null): void; + setOverrideCasing(enabled: boolean): void; } // TODO add migration from previous stored settings @@ -33,6 +35,7 @@ export const useSubtitleStore = create( immer((set) => ({ enabled: false, lastSelectedLanguage: null, + overrideCasing: false, styling: { color: "#ffffff", backgroundOpacity: 0.5, @@ -54,6 +57,11 @@ export const useSubtitleStore = create( if (lang) s.lastSelectedLanguage = lang; }); }, + setOverrideCasing(enabled) { + set((s) => { + s.overrideCasing = enabled; + }); + }, })), { name: "__MW::subtitles",