1
0
Fork 0
mirror of https://github.com/sussy-code/smov.git synced 2024-12-20 14:37:43 +01:00

subtitle scraping + new subtitle setting to fix capitalization

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
mrjvs 2023-10-22 17:58:49 +02:00
parent 851bbb2203
commit 4c782b0c47
14 changed files with 318 additions and 103 deletions

View file

@ -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": {

View file

@ -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

View file

@ -11,8 +11,8 @@ function getProxyUrl(): string {
return url;
}
type P<T> = Parameters<typeof ofetch<T>>;
type R<T> = ReturnType<typeof ofetch<T>>;
type P<T> = Parameters<typeof ofetch<T, any>>;
type R<T> = ReturnType<typeof ofetch<T, any>>;
const baseFetch = ofetch.create({
retry: 0,
@ -50,6 +50,9 @@ export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
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<T>(getProxyUrl(), {
...ops,
@ -57,6 +60,7 @@ export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
params: {
destination: parsedUrl.toString(),
},
query: {},
});
}

View file

@ -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;
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<string | null> {
const req = await proxiedFetch<SuggestResult[]>(
`https://www.opensubtitles.org/libs/suggest.php`,
interface SubtitleSearchData {
subtitleSearch: {
data: RawSubtitleSearchItem[];
};
}
export async function searchSubtitles(
meta: PlayerMeta
): Promise<SubtitleSearchItem[]> {
const data = await request<SubtitleSearchData>({
document: subtitleSearchQuery,
url: GQL_API,
variables: {
tmdb_id: meta.tmdbId,
ep: meta.episode?.number,
season: meta.season?.number,
},
});
const sortedByLanguage: Record<string, RawSubtitleSearchItem[]> = {};
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<string> {
// const downloadScript = await proxiedFetch<string>(
// `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/<LONG_HASH_OF_UPPERCASE_HEX>/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<string> {
// 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<ArrayBuffer>(
`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<Subtitle[]> {
// TODO support episodes
const document = await proxiedFetch<string>(
`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<ID>"
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<string> {
// TODO download, unzip and return srt data
return testSubData.srtData;
}
/**
* None of this works, CF protected endpoints :(
*/

View file

@ -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<Icons, string> = {
mail: `<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19.25 4.125H2.75C2.56766 4.125 2.3928 4.19743 2.26386 4.32636C2.13493 4.4553 2.0625 4.63016 2.0625 4.8125V16.5C2.0625 16.8647 2.20737 17.2144 2.46523 17.4723C2.72309 17.7301 3.07283 17.875 3.4375 17.875H18.5625C18.9272 17.875 19.2769 17.7301 19.5348 17.4723C19.7926 17.2144 19.9375 16.8647 19.9375 16.5V4.8125C19.9375 4.63016 19.8651 4.4553 19.7361 4.32636C19.6072 4.19743 19.4323 4.125 19.25 4.125ZM8.48289 11L3.4375 15.6243V6.3757L8.48289 11ZM9.50039 11.9324L10.5316 12.882C10.6585 12.9985 10.8244 13.0631 10.9966 13.0631C11.1687 13.0631 11.3346 12.9985 11.4615 12.882L12.4927 11.9324L17.4771 16.5H4.51773L9.50039 11.9324ZM13.5171 11L18.5625 6.37484V15.6252L13.5171 11Z" fill="currentColor" /></svg>`,
circle_check: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><path fill="currentColor" d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>`,
skip_episode: `<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.625 2.8125V15.1875C14.625 15.3367 14.5657 15.4798 14.4602 15.5852C14.3548 15.6907 14.2117 15.75 14.0625 15.75C13.9133 15.75 13.7702 15.6907 13.6648 15.5852C13.5593 15.4798 13.5 15.3367 13.5 15.1875V10.3198L5.09273 15.5777C4.92342 15.684 4.72878 15.7431 4.52895 15.7489C4.32913 15.7547 4.13139 15.707 3.95621 15.6107C3.78102 15.5144 3.63477 15.373 3.53258 15.2012C3.43039 15.0294 3.37599 14.8333 3.375 14.6334V3.36656C3.37599 3.16666 3.43039 2.97065 3.53258 2.79883C3.63477 2.62702 3.78102 2.48564 3.95621 2.38933C4.13139 2.29303 4.32913 2.2453 4.52895 2.25109C4.72878 2.25688 4.92342 2.31598 5.09273 2.42227L13.5 7.68023V2.8125C13.5 2.66332 13.5593 2.52024 13.6648 2.41475C13.7702 2.30926 13.9133 2.25 14.0625 2.25C14.2117 2.25 14.3548 2.30926 14.4602 2.41475C14.5657 2.52024 14.625 2.66332 14.625 2.8125Z" fill="currentColor"/></svg>`,
ios_share: `<svg width="20" height="24" viewBox="0 0 20 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 15.3857C10.4409 15.3857 10.8101 15.0166 10.8101 14.5859V4.05518L10.7485 2.51709L11.4355 3.24512L12.9941 4.90625C13.1377 5.07031 13.353 5.15234 13.5479 5.15234C13.9683 5.15234 14.2964 4.84473 14.2964 4.42432C14.2964 4.20898 14.2041 4.04492 14.0503 3.89111L10.5845 0.54834C10.3794 0.343262 10.2051 0.271484 10 0.271484C9.78467 0.271484 9.61035 0.343262 9.40527 0.54834L5.93945 3.89111C5.78564 4.04492 5.69336 4.20898 5.69336 4.42432C5.69336 4.84473 6.00098 5.15234 6.43164 5.15234C6.62646 5.15234 6.85205 5.07031 6.99561 4.90625L8.5542 3.24512L9.24121 2.51709L9.17969 4.05518V14.5859C9.17969 15.0166 9.55908 15.3857 10 15.3857ZM4.11426 23.4146H15.8755C18.0186 23.4146 19.0952 22.3481 19.0952 20.2358V10.0024C19.0952 7.89014 18.0186 6.82373 15.8755 6.82373H13.0146V8.47461H15.8447C16.8599 8.47461 17.4443 9.02832 17.4443 10.0947V20.1436C17.4443 21.21 16.8599 21.7637 15.8447 21.7637H4.13477C3.10938 21.7637 2.54541 21.21 2.54541 20.1436V10.0947C2.54541 9.02832 3.10938 8.47461 4.13477 8.47461H6.9751V6.82373H4.11426C1.97119 6.82373 0.894531 7.89014 0.894531 10.0024V20.2358C0.894531 22.3481 1.97119 23.4146 4.11426 23.4146Z" fill="currentColor"/></svg>`,
};
function ChromeCastButton() {

View file

@ -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 }) {
</Menu.Card>
</OverlayPage>
<OverlayPage id={id} path="/captions" width={343} height={431}>
<Menu.Card>
<Menu.CardWithScrollable>
<CaptionsView id={id} />
</Menu.Card>
</Menu.CardWithScrollable>
</OverlayPage>
<OverlayPage id={id} path="/captions/settings" width={343} height={310}>
<OverlayPage id={id} path="/captions/settings" width={343} height={360}>
<Menu.Card>
<CaptionSettingsView id={id} />
</Menu.Card>
@ -70,6 +71,11 @@ function SettingsOverlay({ id }: { id: string }) {
<PlaybackSettingsView id={id} />
</Menu.Card>
</OverlayPage>
<OverlayPage id={id} path="/download" width={343} height={431}>
<Menu.Card>
<DownloadView id={id} />
</Menu.Card>
</OverlayPage>
</OverlayRouter>
</Overlay>
);

View file

@ -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 }) {
))}
</div>
</div>
<div className="flex justify-between items-center">
<Menu.FieldTitle>Fix capitalization</Menu.FieldTitle>
<div className="flex justify-center items-center">
<Toggle
enabled={overrideCasing}
onClick={() => setOverrideCasing(!overrideCasing)}
/>
</div>
</div>
</Menu.Section>
</>
);

View file

@ -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<string, string> = {
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 (
<SelectableLink selected={props.selected} onClick={props.onClick}>
<span className="flex items-center">
<span className="mr-3">
<FlagIcon countryCode={props.countryCode} />
<span data-code={props.countryCode} className="mr-3">
<FlagIcon countryCode={countryCode} />
</span>
<span>{props.children}</span>
</span>
@ -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 = <p>loading...</p>;
else if (req.error) content = <p>errored!</p>;
else if (req.value)
content = req.value.subs.map((v) => (
content = req.value.map((v) => (
<CaptionOption
key={v.id}
countryCode={v.language}
selected={lang === v.language}
onClick={() => 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"}
</CaptionOption>
));

View file

@ -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 (
<>
<Menu.BackLink onClick={() => router.navigate("/")}>
Download
</Menu.BackLink>
<Menu.Section>
<div className="space-y-4 mt-3">
<Menu.Paragraph>
Downloads are taken directly from the provider. movie-web does not
have control over how the downloads are provided.
</Menu.Paragraph>
<Menu.Paragraph>
To download on iOS, click <Menu.Highlight>Share</Menu.Highlight>,
then <Menu.Highlight>Save to File</Menu.Highlight> and then as after
you click the button.
</Menu.Paragraph>
<Menu.Paragraph>
To download on Android or PC, click or tap and hold on the video,
then select save as.
</Menu.Paragraph>
</div>
</Menu.Section>
</>
);
}

View file

@ -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 (
<Menu.Card>
<Menu.SectionTitle>Video settings</Menu.SectionTitle>
@ -52,6 +57,7 @@ export function SettingsMenu({ id }: { id: string }) {
</Menu.ChevronLink>
<Menu.Link
clickable
onClick={() => router.navigate("/download")}
rightSide={<Icon className="text-xl" icon={Icons.DOWNLOAD} />}
>
Download
@ -72,7 +78,7 @@ export function SettingsMenu({ id }: { id: string }) {
</Menu.Link>
<Menu.ChevronLink
onClick={() => router.navigate("/captions")}
rightText={selectedCaptionLanguage}
rightText={selectedLanguagePretty}
>
Caption settings
</Menu.ChevronLink>

View file

@ -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, "<br />");
const wordOverrides: Record<string, string> = {
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, "<br />");
// https://www.w3.org/TR/webvtt1/#dom-construction-rules
// added a <br /> 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}
/>
))}
</div>

View file

@ -49,6 +49,14 @@ export function FieldTitle(props: { children: React.ReactNode }) {
return <p className="font-medium">{props.children}</p>;
}
export function Paragraph(props: { children: React.ReactNode }) {
return <p className="my-3">{props.children}</p>;
}
export function Highlight(props: { children: React.ReactNode }) {
return <span className="text-white">{props.children}</span>;
}
export function TextDisplay(props: {
children: React.ReactNode;
title?: string;

View file

@ -1331,3 +1331,8 @@ export const captionLanguages: CaptionLanguageOption[] = [
nativeName: "IsiZulu",
},
];
export const languageMap: Record<string, CaptionLanguageOption> = {};
captionLanguages.forEach((v) => {
languageMap[v.id] = v;
});

View file

@ -23,8 +23,10 @@ export interface SubtitleStore {
enabled: boolean;
lastSelectedLanguage: string | null;
styling: SubtitleStyling;
overrideCasing: boolean;
updateStyling(newStyling: Partial<SubtitleStyling>): 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<SubtitleStore>((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",