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:
parent
851bbb2203
commit
4c782b0c47
14 changed files with 318 additions and 103 deletions
|
@ -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": {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface Subtitle {
|
||||
id: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const metaTypeToOpenSubs = {
|
||||
tv: "show",
|
||||
movie: "movie",
|
||||
} as const;
|
||||
|
||||
export async function getOpenSubsId(meta: PlayerMeta): Promise<string | null> {
|
||||
const req = await proxiedFetch<SuggestResult[]>(
|
||||
`https://www.opensubtitles.org/libs/suggest.php`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Alt-Used": "www.opensubtitles.org",
|
||||
"X-Referer": "https://www.opensubtitles.org/en/search/subs",
|
||||
},
|
||||
query: {
|
||||
format: "json",
|
||||
MovieName: meta.title,
|
||||
},
|
||||
}
|
||||
);
|
||||
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();
|
||||
}
|
||||
|
||||
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,
|
||||
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 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;
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* None of this works, CF protected endpoints :(
|
||||
*/
|
||||
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}`,
|
||||
{
|
||||
responseType: "arrayBuffer",
|
||||
}
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
));
|
||||
|
||||
|
|
31
src/components/player/atoms/settings/DownloadView.tsx
Normal file
31
src/components/player/atoms/settings/DownloadView.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1331,3 +1331,8 @@ export const captionLanguages: CaptionLanguageOption[] = [
|
|||
nativeName: "IsiZulu",
|
||||
},
|
||||
];
|
||||
|
||||
export const languageMap: Record<string, CaptionLanguageOption> = {};
|
||||
captionLanguages.forEach((v) => {
|
||||
languageMap[v.id] = v;
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue