linked captions + primary navigation dropdown

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
mrjvs 2023-11-19 16:49:17 +01:00
parent 9152ad7bb0
commit fa990d16b2
19 changed files with 361 additions and 327 deletions

View File

@ -6,7 +6,7 @@
"dependencies": { "dependencies": {
"@formkit/auto-animate": "^0.7.0", "@formkit/auto-animate": "^0.7.0",
"@headlessui/react": "^1.5.0", "@headlessui/react": "^1.5.0",
"@movie-web/providers": "^1.0.5", "@movie-web/providers": "^1.1.2",
"@noble/hashes": "^1.3.2", "@noble/hashes": "^1.3.2",
"@react-spring/web": "^9.7.1", "@react-spring/web": "^9.7.1",
"@scure/bip39": "^1.2.1", "@scure/bip39": "^1.2.1",
@ -18,7 +18,6 @@
"flag-icons": "^6.11.1", "flag-icons": "^6.11.1",
"fscreen": "^1.2.0", "fscreen": "^1.2.0",
"fuse.js": "^6.4.6", "fuse.js": "^6.4.6",
"graphql-request": "^6.1.0",
"hls.js": "^1.0.7", "hls.js": "^1.0.7",
"i18next": "^22.4.5", "i18next": "^22.4.5",
"immer": "^10.0.2", "immer": "^10.0.2",
@ -35,7 +34,6 @@
"react-use": "^17.4.0", "react-use": "^17.4.0",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"subsrt-ts": "^2.1.1", "subsrt-ts": "^2.1.1",
"unzipit": "^1.4.3",
"zustand": "^4.3.9" "zustand": "^4.3.9"
}, },
"scripts": { "scripts": {

View File

@ -18,8 +18,8 @@ dependencies:
specifier: ^1.5.0 specifier: ^1.5.0
version: 1.7.17(react-dom@17.0.2)(react@17.0.2) version: 1.7.17(react-dom@17.0.2)(react@17.0.2)
'@movie-web/providers': '@movie-web/providers':
specifier: ^1.0.5 specifier: ^1.1.2
version: 1.0.5 version: 1.1.2
'@noble/hashes': '@noble/hashes':
specifier: ^1.3.2 specifier: ^1.3.2
version: 1.3.2 version: 1.3.2
@ -53,9 +53,6 @@ dependencies:
fuse.js: fuse.js:
specifier: ^6.4.6 specifier: ^6.4.6
version: 6.6.2 version: 6.6.2
graphql-request:
specifier: ^6.1.0
version: 6.1.0(graphql@16.8.1)
hls.js: hls.js:
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.4.11 version: 1.4.11
@ -104,9 +101,6 @@ dependencies:
subsrt-ts: subsrt-ts:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
unzipit:
specifier: ^1.4.3
version: 1.4.3
zustand: zustand:
specifier: ^4.3.9 specifier: ^4.3.9
version: 4.4.1(@types/react@17.0.65)(immer@10.0.2)(react@17.0.2) version: 4.4.1(@types/react@17.0.65)(immer@10.0.2)(react@17.0.2)
@ -1802,14 +1796,6 @@ packages:
resolution: {integrity: sha512-RczHUr0AhRPssREoNdRjLfk2b/id9/DFnbIq18QM8L7E4zNV3XH+WO480EZ46BQHDEsv76YPJ0JbG2Y2i3GfXw==} resolution: {integrity: sha512-RczHUr0AhRPssREoNdRjLfk2b/id9/DFnbIq18QM8L7E4zNV3XH+WO480EZ46BQHDEsv76YPJ0JbG2Y2i3GfXw==}
dev: false 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): /@headlessui/react@1.7.17(react-dom@17.0.2)(react@17.0.2):
resolution: {integrity: sha512-4am+tzvkqDSSgiwrsEpGWqgGo9dz8qU5M3znCkC4PgkpY4HcCZzEDEvozltGGGHIKl9jbXbZPSH5TWn4sWJdow==} resolution: {integrity: sha512-4am+tzvkqDSSgiwrsEpGWqgGo9dz8qU5M3znCkC4PgkpY4HcCZzEDEvozltGGGHIKl9jbXbZPSH5TWn4sWJdow==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -1891,12 +1877,13 @@ packages:
'@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/sourcemap-codec': 1.4.15
dev: true dev: true
/@movie-web/providers@1.0.5: /@movie-web/providers@1.1.2:
resolution: {integrity: sha512-/JnfH6LcERzU2AVJ0MKyRg2DHIyTc4cPipYW2tZ8h4OaQLRG0fujUT2isAZbA/PaffEfkzOQd+XdSwejNCqr8w==} resolution: {integrity: sha512-ZPSHBoz9WFLc6bWnRAXpefE+Vf8GNJ4xuWv5gu+uNg7dNBIMCnPqeuABlNIGxpEi68Go7zYlyx6nH/GQItgweA==}
dependencies: dependencies:
cheerio: 1.0.0-rc.12 cheerio: 1.0.0-rc.12
crypto-js: 4.2.0 crypto-js: 4.2.0
form-data: 4.0.0 form-data: 4.0.0
iso-639-1: 3.1.0
nanoid: 3.3.6 nanoid: 3.3.6
node-fetch: 2.7.0 node-fetch: 2.7.0
unpacker: 1.0.1 unpacker: 1.0.1
@ -2957,14 +2944,6 @@ packages:
cross-spawn: 7.0.3 cross-spawn: 7.0.3
dev: true dev: true
/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: /cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -3964,23 +3943,6 @@ packages:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
dev: true 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: /handlebars@4.7.8:
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
engines: {node: '>=0.4.7'} engines: {node: '>=0.4.7'}
@ -4349,6 +4311,11 @@ packages:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
dev: true dev: true
/iso-639-1@3.1.0:
resolution: {integrity: sha512-rWcHp9dcNbxa5C8jA/cxFlWNFNwy5Vup0KcFvgA8sPQs9ZeJHj/Eq0Y8Yz2eL8XlWYpxw4iwh9FfTeVxyqdRMw==}
engines: {node: '>=6.0'}
dev: false
/jackspeak@2.3.1: /jackspeak@2.3.1:
resolution: {integrity: sha512-4iSY3Bh1Htv+kLhiiZunUhQ+OYXIn0ze3ulq8JeWrFKmhPAJSySV2+kdtRh2pGcCeF0s6oR8Oc+pYZynJj4t8A==} resolution: {integrity: sha512-4iSY3Bh1Htv+kLhiiZunUhQ+OYXIn0ze3ulq8JeWrFKmhPAJSySV2+kdtRh2pGcCeF0s6oR8Oc+pYZynJj4t8A==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -6178,13 +6145,6 @@ packages:
resolution: {integrity: sha512-0HTljwp8+JBdITpoHcK1LWi7X9U2BspUmWv78UWZh7NshYhbh1nec8baY/iSbe2OQTZ2bhAtVdnr6/BTD0DKVg==} resolution: {integrity: sha512-0HTljwp8+JBdITpoHcK1LWi7X9U2BspUmWv78UWZh7NshYhbh1nec8baY/iSbe2OQTZ2bhAtVdnr6/BTD0DKVg==}
dev: false 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: /upath@1.2.0:
resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -6226,10 +6186,6 @@ packages:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true dev: true
/uzip-module@1.0.3:
resolution: {integrity: sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==}
dev: false
/value-equal@1.0.1: /value-equal@1.0.1:
resolution: {integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==} resolution: {integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==}
dev: false dev: false

View File

@ -1,116 +1,33 @@
import { gql, request } from "graphql-request";
import { list } from "subsrt-ts"; import { list } from "subsrt-ts";
import { unzip } from "unzipit";
import { proxiedFetch } from "@/backend/helpers/fetch"; import { proxiedFetch } from "@/backend/helpers/fetch";
import { languageMap } from "@/setup/iso6391"; import { convertSubtitlesToSrt } from "@/components/player/utils/captions";
import { PlayerMeta } from "@/stores/player/slices/source"; import { CaptionListItem } from "@/stores/player/slices/source";
import { SimpleCache } from "@/utils/cache";
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
}
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;
};
}
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;
};
}
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 onlyLegacySubs = langs.filter(
(v): v is SubtitleSearchItem => !!v.attributes.legacy_subtitle_id
);
const sortedByRating = onlyLegacySubs.sort(
(a, b) =>
b.attributes.ratings * (b.attributes.votes ?? 0) -
a.attributes.ratings * (a.attributes.votes ?? 0)
);
return sortedByRating[0];
});
}
export async function downloadSrt(legacySubId: string): Promise<string> {
// TODO there is cloudflare protection so this may not always work. what to do about that?
// TODO also there is ratelimit on the page itself
// 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;
}
export const subtitleTypeList = list().map((type) => `.${type}`); export const subtitleTypeList = list().map((type) => `.${type}`);
const downloadCache = new SimpleCache<string, string>();
downloadCache.setCompare((a, b) => a === b);
const expirySeconds = 24 * 60 * 60;
/**
* Always returns SRT
*/
export async function downloadCaption(
caption: CaptionListItem
): Promise<string> {
const cached = downloadCache.get(caption.url);
if (cached) return cached;
let data: string | undefined;
if (caption.needsProxy) {
data = await proxiedFetch<string>(caption.url, { responseType: "text" });
} else {
data = await fetch(caption.url).then((v) => v.text());
}
if (!data) throw new Error("failed to get caption data");
const output = convertSubtitlesToSrt(data);
downloadCache.set(caption.url, output, expirySeconds);
return output;
}

View File

@ -52,6 +52,10 @@ export enum Icons {
COPY = "copy", COPY = "copy",
USER = "user", USER = "user",
UP_DOWN_ARROW = "up_down_arrow", UP_DOWN_ARROW = "up_down_arrow",
RISING_STAR = "rising_star",
SETTINGS = "settings",
COINS = "coins",
LOGOUT = "logout",
} }
export interface IconProps { export interface IconProps {
@ -111,6 +115,10 @@ const iconList: Record<Icons, string> = {
copy: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-copy"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`, copy: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-copy"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`,
user: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-user"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`, user: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-user"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`,
up_down_arrow: `<svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4.53803 5.19018C4.50013 5.09883 4.49018 4.99829 4.50942 4.90128C4.52867 4.80427 4.57625 4.71514 4.64616 4.64518L7.64616 1.64518C7.69259 1.59869 7.74774 1.56181 7.80844 1.53665C7.86913 1.51149 7.9342 1.49854 7.99991 1.49854C8.06561 1.49854 8.13068 1.51149 8.19138 1.53665C8.25207 1.56181 8.30722 1.59869 8.35366 1.64518L11.3537 4.64518C11.4237 4.71511 11.4713 4.80423 11.4907 4.90128C11.51 4.99832 11.5001 5.09891 11.4622 5.19032C11.4243 5.28174 11.3602 5.35985 11.2779 5.41479C11.1956 5.46972 11.0989 5.49901 10.9999 5.49893H4.99991C4.90102 5.49891 4.80435 5.46956 4.72214 5.41461C4.63993 5.35965 4.57586 5.28155 4.53803 5.19018ZM10.9999 10.4989H4.99991C4.90096 10.4988 4.80421 10.5281 4.72191 10.5831C4.63962 10.638 4.57547 10.7161 4.53759 10.8075C4.49972 10.8989 4.48982 10.9995 4.50914 11.0966C4.52847 11.1936 4.57615 11.2828 4.64616 11.3527L7.64616 14.3527C7.69259 14.3992 7.74774 14.436 7.80844 14.4612C7.86913 14.4864 7.9342 14.4993 7.99991 14.4993C8.06561 14.4993 8.13068 14.4864 8.19138 14.4612C8.25207 14.436 8.30722 14.3992 8.35366 14.3527L11.3537 11.3527C11.4237 11.2828 11.4713 11.1936 11.4907 11.0966C11.51 10.9995 11.5001 10.8989 11.4622 10.8075C11.4243 10.7161 11.3602 10.638 11.2779 10.5831C11.1956 10.5281 11.0989 10.4988 10.9999 10.4989Z" fill="currentColor"/></svg>`, up_down_arrow: `<svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4.53803 5.19018C4.50013 5.09883 4.49018 4.99829 4.50942 4.90128C4.52867 4.80427 4.57625 4.71514 4.64616 4.64518L7.64616 1.64518C7.69259 1.59869 7.74774 1.56181 7.80844 1.53665C7.86913 1.51149 7.9342 1.49854 7.99991 1.49854C8.06561 1.49854 8.13068 1.51149 8.19138 1.53665C8.25207 1.56181 8.30722 1.59869 8.35366 1.64518L11.3537 4.64518C11.4237 4.71511 11.4713 4.80423 11.4907 4.90128C11.51 4.99832 11.5001 5.09891 11.4622 5.19032C11.4243 5.28174 11.3602 5.35985 11.2779 5.41479C11.1956 5.46972 11.0989 5.49901 10.9999 5.49893H4.99991C4.90102 5.49891 4.80435 5.46956 4.72214 5.41461C4.63993 5.35965 4.57586 5.28155 4.53803 5.19018ZM10.9999 10.4989H4.99991C4.90096 10.4988 4.80421 10.5281 4.72191 10.5831C4.63962 10.638 4.57547 10.7161 4.53759 10.8075C4.49972 10.8989 4.48982 10.9995 4.50914 11.0966C4.52847 11.1936 4.57615 11.2828 4.64616 11.3527L7.64616 14.3527C7.69259 14.3992 7.74774 14.436 7.80844 14.4612C7.86913 14.4864 7.9342 14.4993 7.99991 14.4993C8.06561 14.4993 8.13068 14.4864 8.19138 14.4612C8.25207 14.436 8.30722 14.3992 8.35366 14.3527L11.3537 11.3527C11.4237 11.2828 11.4713 11.1936 11.4907 11.0966C11.51 10.9995 11.5001 10.8989 11.4622 10.8075C11.4243 10.7161 11.3602 10.638 11.2779 10.5831C11.1956 10.5281 11.0989 10.4988 10.9999 10.4989Z" fill="currentColor"/></svg>`,
rising_star: `<svg width="1em" height="1em" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17.5509 6.91102L15.5716 8.59852L16.1643 11.1108C16.2061 11.2869 16.195 11.4714 16.1325 11.6412C16.0699 11.811 15.9587 11.9587 15.8127 12.0656C15.6651 12.174 15.4888 12.2365 15.3058 12.2453C15.1229 12.254 14.9414 12.2087 14.7841 12.1148L12.5341 10.7789L10.2841 12.1148C10.1268 12.2087 9.94528 12.254 9.76231 12.2453C9.57935 12.2365 9.40303 12.174 9.2554 12.0656C9.10948 11.9586 8.99833 11.811 8.9358 11.6412C8.87328 11.4713 8.86216 11.2869 8.90384 11.1108L9.49657 8.59852L7.51657 6.91102C7.37802 6.79275 7.27755 6.63613 7.22781 6.46088C7.17808 6.28563 7.1813 6.09959 7.23708 5.92617C7.29286 5.75275 7.39869 5.59971 7.54126 5.48631C7.68383 5.37291 7.85677 5.30423 8.03829 5.28891L10.656 5.06742L11.677 2.68734C11.749 2.52049 11.8683 2.37837 12.0202 2.27853C12.1721 2.17869 12.3499 2.12549 12.5316 2.12549C12.7134 2.12549 12.8911 2.17869 13.043 2.27853C13.1949 2.37837 13.3142 2.52049 13.3863 2.68734L14.4072 5.06883L17.0242 5.28891C17.2062 5.30319 17.3798 5.37111 17.5231 5.48409C17.6665 5.59707 17.7731 5.75002 17.8294 5.9236C17.8858 6.09718 17.8894 6.28358 17.8399 6.45922C17.7903 6.63486 17.6897 6.79185 17.5509 6.91031V6.91102ZM7.02298 9.03938C6.97074 8.98708 6.9087 8.94559 6.84041 8.91728C6.77213 8.88897 6.69893 8.8744 6.62501 8.8744C6.55109 8.8744 6.47789 8.88897 6.4096 8.91728C6.34132 8.94559 6.27928 8.98708 6.22704 9.03938L2.28954 12.9769C2.18399 13.0824 2.12469 13.2256 2.12469 13.3748C2.12469 13.5241 2.18399 13.6673 2.28954 13.7728C2.39509 13.8784 2.53824 13.9377 2.68751 13.9377C2.83677 13.9377 2.97993 13.8784 3.08548 13.7728L7.02298 9.83531C7.07528 9.78307 7.11677 9.72104 7.14507 9.65275C7.17338 9.58446 7.18795 9.51127 7.18795 9.43735C7.18795 9.36342 7.17338 9.29023 7.14507 9.22194C7.11677 9.15365 7.07528 9.09162 7.02298 9.03938ZM8.14798 12.9769C8.09574 12.9246 8.0337 12.8831 7.96541 12.8548C7.89713 12.8265 7.82393 12.8119 7.75001 12.8119C7.67609 12.8119 7.60289 12.8265 7.5346 12.8548C7.46632 12.8831 7.40428 12.9246 7.35204 12.9769L3.41454 16.9144C3.36228 16.9666 3.32082 17.0287 3.29254 17.097C3.26425 17.1652 3.24969 17.2384 3.24969 17.3123C3.24969 17.3863 3.26425 17.4594 3.29254 17.5277C3.32082 17.596 3.36228 17.6581 3.41454 17.7103C3.52009 17.8159 3.66324 17.8752 3.81251 17.8752C3.88642 17.8752 3.9596 17.8606 4.02789 17.8323C4.09617 17.804 4.15821 17.7626 4.21048 17.7103L8.14798 13.7728C8.20028 13.7206 8.24177 13.6585 8.27007 13.5902C8.29838 13.522 8.31295 13.4488 8.31295 13.3748C8.31295 13.3009 8.29838 13.2277 8.27007 13.1594C8.24177 13.0912 8.20028 13.0291 8.14798 12.9769ZM12.4152 12.9769L8.47774 16.9144C8.37219 17.0199 8.3129 17.1631 8.3129 17.3123C8.3129 17.4616 8.37219 17.6048 8.47774 17.7103C8.58329 17.8159 8.72644 17.8752 8.87571 17.8752C9.02498 17.8752 9.16813 17.8159 9.27368 17.7103L13.2112 13.7728C13.3167 13.6674 13.3761 13.5243 13.3761 13.3751C13.3762 13.2259 13.317 13.0828 13.2115 12.9772C13.1061 12.8717 12.963 12.8123 12.8138 12.8123C12.6646 12.8122 12.5215 12.8714 12.4159 12.9769H12.4152Z" fill="currentColor"/></svg>`,
settings: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-settings"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
coins: `<svg width="1em" height="1em" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M15.8125 7.69742V7.21875C15.8125 5.06344 12.5615 3.4375 8.25 3.4375C3.93852 3.4375 0.6875 5.06344 0.6875 7.21875V10.6562C0.6875 12.4515 2.94336 13.878 6.1875 14.3052V14.7812C6.1875 16.9366 9.43852 18.5625 13.75 18.5625C18.0615 18.5625 21.3125 16.9366 21.3125 14.7812V11.3438C21.3125 9.56484 19.128 8.13656 15.8125 7.69742ZM4.8125 12.6216C3.12898 12.1516 2.0625 11.3773 2.0625 10.6562V9.44711C2.76375 9.94383 3.70305 10.3443 4.8125 10.6133V12.6216ZM11.6875 10.6133C12.797 10.3443 13.7362 9.94383 14.4375 9.44711V10.6562C14.4375 11.3773 13.371 12.1516 11.6875 12.6216V10.6133ZM10.3125 16.7466C8.62898 16.2766 7.5625 15.5023 7.5625 14.7812V14.4229C7.78852 14.4315 8.01711 14.4375 8.25 14.4375C8.58344 14.4375 8.90914 14.4263 9.22883 14.4074C9.58397 14.5346 9.94572 14.6424 10.3125 14.7305V16.7466ZM10.3125 12.9121C9.62964 13.013 8.94027 13.0633 8.25 13.0625C7.55973 13.0633 6.87036 13.013 6.1875 12.9121V10.8677C6.87137 10.9568 7.56035 11.001 8.25 11C8.93965 11.001 9.62863 10.9568 10.3125 10.8677V12.9121ZM15.8125 17.0371C14.4448 17.2376 13.0552 17.2376 11.6875 17.0371V14.9875C12.3712 15.0794 13.0602 15.1253 13.75 15.125C14.4397 15.126 15.1286 15.0818 15.8125 14.9927V17.0371ZM19.9375 14.7812C19.9375 15.5023 18.871 16.2766 17.1875 16.7466V14.7383C18.297 14.4693 19.2362 14.0688 19.9375 13.5721V14.7812Z" fill="currentColor"/></svg>`,
logout: `<svg style="transform: scaleX(-1);" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-log-out"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>`,
}; };
function ChromeCastButton() { function ChromeCastButton() {

View File

@ -0,0 +1,152 @@
import classNames from "classnames";
import { useCallback, useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import { UserAvatar } from "@/components/Avatar";
import { Icon, Icons } from "@/components/Icon";
import { Transition } from "@/components/Transition";
import { useAuth } from "@/hooks/auth/useAuth";
import { conf } from "@/setup/config";
import { useAuthStore } from "@/stores/auth";
function Divider() {
return <hr className="border-0 w-full h-px bg-dropdown-border" />;
}
function GoToLink(props: {
children: React.ReactNode;
href?: string;
className?: string;
onClick?: () => void;
}) {
const history = useHistory();
const goTo = (href: string) => {
if (href.startsWith("http")) window.open(href, "_blank");
else history.push(href);
};
return (
<a
href={props.href}
onClick={(evt) => {
evt.preventDefault();
if (props.href) goTo(props.href);
else props.onClick?.();
}}
className={props.className}
>
{props.children}
</a>
);
}
function DropdownLink(props: {
children: React.ReactNode;
href?: string;
icon?: Icons;
highlight?: boolean;
className?: string;
onClick?: () => void;
}) {
return (
<GoToLink
onClick={props.onClick}
href={props.href}
className={classNames(
"cursor-pointer flex gap-3 items-center m-4 font-medium transition-colors duration-100",
props.highlight
? "text-dropdown-highlight hover:text-dropdown-highlightHover"
: "text-dropdown-text hover:text-white",
props.className
)}
>
{props.icon ? <Icon icon={props.icon} className="text-xl" /> : null}
{props.children}
</GoToLink>
);
}
function CircleDropdownLink(props: { icon: Icons; href: string }) {
return (
<GoToLink
href={props.href}
className="w-11 h-11 rounded-full bg-dropdown-contentBackground text-dropdown-text hover:text-white transition-colors duration-100 flex justify-center items-center"
>
<Icon className="text-2xl" icon={props.icon} />
</GoToLink>
);
}
export function LinksDropdown(props: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const userId = useAuthStore((s) => s.account?.userId);
const { logout } = useAuth();
useEffect(() => {
function onWindowClick(evt: MouseEvent) {
if ((evt.target as HTMLElement).closest(".is-dropdown")) return;
setOpen(false);
}
window.addEventListener("click", onWindowClick);
return () => window.removeEventListener("click", onWindowClick);
}, []);
const toggleOpen = useCallback(() => {
setOpen((s) => !s);
}, []);
return (
<div className="relative is-dropdown">
<div className="cursor-pointer" onClick={toggleOpen}>
{props.children}
</div>
<Transition animation="slide-down" show={open}>
<div className="rounded-lg absolute w-64 bg-dropdown-altBackground top-full mt-3 right-0">
{userId ? (
<DropdownLink className="text-white" href="/settings">
<UserAvatar />
{userId}
</DropdownLink>
) : (
<DropdownLink href="/login" icon={Icons.RISING_STAR} highlight>
Sync to cloud
</DropdownLink>
)}
<Divider />
<DropdownLink href="/settings" icon={Icons.SETTINGS}>
Settings
</DropdownLink>
<DropdownLink href="/faq" icon={Icons.EPISODES}>
About us
</DropdownLink>
<DropdownLink href="/faq" icon={Icons.FILM}>
HELP MEEE
</DropdownLink>
{userId ? (
<DropdownLink
className="!text-type-danger opacity-75 hover:opacity-100"
icon={Icons.LOGOUT}
onClick={logout}
>
Log out
</DropdownLink>
) : null}
<Divider />
<div className="my-4 flex justify-center items-center gap-4">
<CircleDropdownLink
href={conf().DISCORD_LINK}
icon={Icons.DISCORD}
/>
<CircleDropdownLink href={conf().GITHUB_LINK} icon={Icons.GITHUB} />
<CircleDropdownLink
href={conf().DONATION_LINK}
icon={Icons.COINS}
/>
</div>
</div>
</Transition>
</div>
);
}

View File

@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
import { UserAvatar } from "@/components/Avatar"; import { UserAvatar } from "@/components/Avatar";
import { IconPatch } from "@/components/buttons/IconPatch"; import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon"; import { Icons } from "@/components/Icon";
import { LinksDropdown } from "@/components/LinksDropdown";
import { Lightbar } from "@/components/utils/Lightbar"; import { Lightbar } from "@/components/utils/Lightbar";
import { useAuth } from "@/hooks/auth/useAuth"; import { useAuth } from "@/hooks/auth/useAuth";
import { BlurEllipsis } from "@/pages/layouts/SubPageLayout"; import { BlurEllipsis } from "@/pages/layouts/SubPageLayout";
@ -37,7 +38,7 @@ export function Navigation(props: NavigationProps) {
</div> </div>
) : null} ) : null}
<div <div
className="fixed pointer-events-none left-0 right-0 top-0 z-10 min-h-[150px]" className="fixed z-[40] pointer-events-none left-0 right-0 top-0 min-h-[150px]"
style={{ style={{
top: `${bannerHeight}px`, top: `${bannerHeight}px`,
}} }}
@ -46,12 +47,14 @@ export function Navigation(props: NavigationProps) {
className={classNames( className={classNames(
"fixed left-0 right-0 flex items-center", "fixed left-0 right-0 flex items-center",
props.doBackground props.doBackground
? "bg-background-main border-b border-utils-divider border-opacity-50 overflow-hidden" ? "bg-background-main border-b border-utils-divider border-opacity-50"
: null : null
)} )}
> >
{props.doBackground ? ( {props.doBackground ? (
<BlurEllipsis positionClass="absolute" /> <div className="absolute w-full h-full inset-0 overflow-hidden">
<BlurEllipsis positionClass="absolute" />
</div>
) : null} ) : null}
<div <div
className={`${ className={`${
@ -82,7 +85,11 @@ export function Navigation(props: NavigationProps) {
<IconPatch icon={Icons.GITHUB} clickable downsized /> <IconPatch icon={Icons.GITHUB} clickable downsized />
</a> </a>
</div> </div>
<div>{loggedIn ? <UserAvatar /> : <p>Not logged in</p>}</div> <div className="relative">
<LinksDropdown>
{loggedIn ? <UserAvatar /> : <p>Not logged in</p>}
</LinksDropdown>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,9 +1,9 @@
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { ReactNode, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { useAsync, useAsyncFn } from "react-use"; import { useAsyncFn } from "react-use";
import { convert } from "subsrt-ts"; import { convert } from "subsrt-ts";
import { SubtitleSearchItem, subtitleTypeList } from "@/backend/helpers/subs"; import { subtitleTypeList } from "@/backend/helpers/subs";
import { FlagIcon } from "@/components/FlagIcon"; import { FlagIcon } from "@/components/FlagIcon";
import { useCaptions } from "@/components/player/hooks/useCaptions"; import { useCaptions } from "@/components/player/hooks/useCaptions";
import { Menu } from "@/components/player/internals/ContextMenu"; import { Menu } from "@/components/player/internals/ContextMenu";
@ -11,6 +11,7 @@ import { Input } from "@/components/player/internals/ContextMenu/Input";
import { SelectableLink } from "@/components/player/internals/ContextMenu/Links"; import { SelectableLink } from "@/components/player/internals/ContextMenu/Links";
import { getLanguageFromIETF } from "@/components/player/utils/language"; import { getLanguageFromIETF } from "@/components/player/utils/language";
import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { CaptionListItem } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
import { useSubtitleStore } from "@/stores/subtitles"; import { useSubtitleStore } from "@/stores/subtitles";
import { sortLangCodes } from "@/utils/sortLangCodes"; import { sortLangCodes } from "@/utils/sortLangCodes";
@ -43,30 +44,6 @@ export function CaptionOption(props: {
); );
} }
function searchSubs(
subs: (SubtitleSearchItem & { languageName: string })[],
searchQuery: string
) {
const sorted = sortLangCodes(subs.map((t) => t.attributes.language));
let results = subs.sort((a, b) => {
return (
sorted.indexOf(a.attributes.language) -
sorted.indexOf(b.attributes.language)
);
});
if (searchQuery.trim().length > 0) {
const fuse = new Fuse(subs, {
includeScore: true,
keys: ["languageName"],
});
results = fuse.search(searchQuery).map((res) => res.item);
}
return results;
}
function CustomCaptionOption() { function CustomCaptionOption() {
const lang = usePlayerStore((s) => s.caption.selected?.language); const lang = usePlayerStore((s) => s.caption.selected?.language);
const setCaption = usePlayerStore((s) => s.setCaption); const setCaption = usePlayerStore((s) => s.setCaption);
@ -104,67 +81,68 @@ function CustomCaptionOption() {
); );
} }
function useSubtitleList(subs: CaptionListItem[], searchQuery: string) {
return useMemo(() => {
const input = subs.map((t) => ({
...t,
languageName: getLanguageFromIETF(t.language) ?? "Unknown",
}));
const sorted = sortLangCodes(input.map((t) => t.language));
let results = input.sort((a, b) => {
return sorted.indexOf(a.language) - sorted.indexOf(b.language);
});
if (searchQuery.trim().length > 0) {
const fuse = new Fuse(input, {
includeScore: true,
keys: ["languageName"],
});
results = fuse.search(searchQuery).map((res) => res.item);
}
return results;
}, [subs, searchQuery]);
}
export function CaptionsView({ id }: { id: string }) { export function CaptionsView({ id }: { id: string }) {
const router = useOverlayRouter(id); const router = useOverlayRouter(id);
const lang = usePlayerStore((s) => s.caption.selected?.language); const lang = usePlayerStore((s) => s.caption.selected?.language);
const [currentlyDownloading, setCurrentlyDownloading] = useState< const [currentlyDownloading, setCurrentlyDownloading] = useState<
string | null string | null
>(null); >(null);
const { search, download, disable } = useCaptions(); const { selectLanguage, disable } = useCaptions();
const captionList = usePlayerStore((s) => s.captionList);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const subtitleList = useSubtitleList(captionList, searchQuery);
const req = useAsync(async () => search(), [search]);
const [downloadReq, startDownload] = useAsyncFn( const [downloadReq, startDownload] = useAsyncFn(
async (subtitleId: string, language: string) => { async (language: string) => {
setCurrentlyDownloading(subtitleId); setCurrentlyDownloading(language);
return download(subtitleId, language); return selectLanguage(language);
}, },
[download, setCurrentlyDownloading] [selectLanguage, setCurrentlyDownloading]
); );
let content: ReactNode = null; const content = subtitleList.map((v) => {
if (req.loading) content = <p>loading...</p>; return (
else if (req.error) content = <p>errored!</p>; <CaptionOption
else if (req.value) { key={v.language}
const subs = req.value.filter(Boolean).map((v) => { countryCode={v.language}
const languageName = selected={lang === v.language}
getLanguageFromIETF(v.attributes.language) ?? "unknown"; loading={v.language === currentlyDownloading && downloadReq.loading}
return { error={
...v, v.language === currentlyDownloading && downloadReq.error
languageName, ? downloadReq.error
}; : undefined
}); }
onClick={() => startDownload(v.language)}
content = searchSubs(subs, searchQuery).map((v) => { >
return ( {v.languageName}
<CaptionOption </CaptionOption>
key={v.id} );
countryCode={v.attributes.language} });
selected={lang === v.attributes.language}
loading={
v.attributes.legacy_subtitle_id === currentlyDownloading &&
downloadReq.loading
}
error={
v.attributes.legacy_subtitle_id === currentlyDownloading &&
downloadReq.error
? downloadReq.error
: undefined
}
onClick={() =>
startDownload(
v.attributes.legacy_subtitle_id,
v.attributes.language
)
}
>
{v.languageName}
</CaptionOption>
);
});
}
return ( return (
<> <>
@ -186,10 +164,7 @@ export function CaptionsView({ id }: { id: string }) {
<Input value={searchQuery} onInput={setSearchQuery} /> <Input value={searchQuery} onInput={setSearchQuery} />
</div> </div>
</div> </div>
<Menu.ScrollToActiveSection <Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3">
loaded={req.loading}
className="!pt-1 mt-2 pb-3"
>
<CaptionOption onClick={() => disable()} selected={!lang}> <CaptionOption onClick={() => disable()} selected={!lang}>
Off Off
</CaptionOption> </CaptionOption>

View File

@ -1,95 +1,51 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { import { downloadCaption } from "@/backend/helpers/subs";
SubtitleSearchItem,
downloadSrt,
searchSubtitles,
} from "@/backend/helpers/subs";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
import { useSubtitleStore } from "@/stores/subtitles"; import { useSubtitleStore } from "@/stores/subtitles";
import { SimpleCache } from "@/utils/cache";
const cacheTimeSec = 24 * 60 * 60; // 24 hours
const downloadCache = new SimpleCache<string, string>();
downloadCache.setCompare((a, b) => a === b);
const searchCache = new SimpleCache<
{ tmdbId: string; ep?: string; season?: string },
SubtitleSearchItem[]
>();
searchCache.setCompare(
(a, b) => a.tmdbId === b.tmdbId && a.ep === b.ep && a.season === b.season
);
export function useCaptions() { export function useCaptions() {
const setLanguage = useSubtitleStore((s) => s.setLanguage); const setLanguage = useSubtitleStore((s) => s.setLanguage);
const enabled = useSubtitleStore((s) => s.enabled); const enabled = useSubtitleStore((s) => s.enabled);
const setCaption = usePlayerStore((s) => s.setCaption); const setCaption = usePlayerStore((s) => s.setCaption);
const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage); const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage);
const meta = usePlayerStore((s) => s.meta); const captionList = usePlayerStore((s) => s.captionList);
const download = useCallback( const selectLanguage = useCallback(
async (subtitleId: string, language: string) => { async (language: string) => {
let srtData = downloadCache.get(subtitleId); const caption = captionList.find((v) => v.language === language);
if (!srtData) { if (!caption) return;
srtData = await downloadSrt(subtitleId); const srtData = await downloadCaption(caption);
downloadCache.set(subtitleId, srtData, cacheTimeSec);
}
setCaption({ setCaption({
language, language: caption.language,
srtData, srtData,
url: "", // TODO remove url url: caption.url,
}); });
setLanguage(language); setLanguage(language);
}, },
[setCaption, setLanguage] [setLanguage, captionList, setCaption]
); );
const search = useCallback(async () => {
if (!meta) throw new Error("No meta");
const key = {
tmdbId: meta.tmdbId,
ep: meta.episode?.tmdbId,
season: meta.season?.tmdbId,
};
const results = searchCache.get(key);
if (results) return [...results];
const freshResults = await searchSubtitles(meta);
searchCache.set(key, [...freshResults], cacheTimeSec);
return freshResults;
}, [meta]);
const disable = useCallback(async () => { const disable = useCallback(async () => {
setCaption(null); setCaption(null);
setLanguage(null); setLanguage(null);
}, [setCaption, setLanguage]); }, [setCaption, setLanguage]);
const downloadLastUsed = useCallback(async () => { const selectLastUsedLanguage = useCallback(async () => {
const language = lastSelectedLanguage ?? "en"; const language = lastSelectedLanguage ?? "en";
const searchResult = await search(); await selectLanguage(language);
const languageResult = searchResult.find(
(v) => v.attributes.language === language
);
if (!languageResult) return false;
await download(
languageResult.attributes.legacy_subtitle_id,
languageResult.attributes.language
);
return true; return true;
}, [lastSelectedLanguage, search, download]); }, [lastSelectedLanguage, selectLanguage]);
const toggleLastUsed = useCallback(async () => { const toggleLastUsed = useCallback(async () => {
if (enabled) disable(); if (enabled) disable();
else await downloadLastUsed(); else await selectLastUsedLanguage();
}, [downloadLastUsed, disable, enabled]); }, [selectLastUsedLanguage, disable, enabled]);
return { return {
download, selectLanguage,
search,
disable, disable,
downloadLastUsed, selectLastUsedLanguage,
toggleLastUsed, toggleLastUsed,
}; };
} }

View File

@ -1,5 +1,6 @@
import { useInitializePlayer } from "@/components/player/hooks/useInitializePlayer"; import { useInitializePlayer } from "@/components/player/hooks/useInitializePlayer";
import { import {
CaptionListItem,
PlayerMeta, PlayerMeta,
PlayerStatus, PlayerStatus,
playerStatus, playerStatus,
@ -33,6 +34,7 @@ export function usePlayer() {
const setStatus = usePlayerStore((s) => s.setStatus); const setStatus = usePlayerStore((s) => s.setStatus);
const setMeta = usePlayerStore((s) => s.setMeta); const setMeta = usePlayerStore((s) => s.setMeta);
const setSource = usePlayerStore((s) => s.setSource); const setSource = usePlayerStore((s) => s.setSource);
const setCaption = usePlayerStore((s) => s.setCaption);
const setSourceId = usePlayerStore((s) => s.setSourceId); const setSourceId = usePlayerStore((s) => s.setSourceId);
const status = usePlayerStore((s) => s.status); const status = usePlayerStore((s) => s.status);
const shouldStartFromBeginning = usePlayerStore( const shouldStartFromBeginning = usePlayerStore(
@ -57,11 +59,13 @@ export function usePlayer() {
}, },
playMedia( playMedia(
source: SourceSliceSource, source: SourceSliceSource,
captions: CaptionListItem[],
sourceId: string | null, sourceId: string | null,
startAtOverride?: number startAtOverride?: number
) { ) {
const start = startAtOverride ?? getProgress(progressStore.items, meta); const start = startAtOverride ?? getProgress(progressStore.items, meta);
setSource(source, start); setCaption(null);
setSource(source, captions, start);
setSourceId(sourceId); setSourceId(sourceId);
setStatus(playerStatus.PLAYING); setStatus(playerStatus.PLAYING);
init(); init();

View File

@ -9,6 +9,7 @@ import {
scrapeSourceOutputToProviderMetric, scrapeSourceOutputToProviderMetric,
useReportProviders, useReportProviders,
} from "@/backend/helpers/report"; } from "@/backend/helpers/report";
import { convertProviderCaption } from "@/components/player/utils/captions";
import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource"; import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource";
import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { metaToScrapeMedia } from "@/stores/player/slices/source"; import { metaToScrapeMedia } from "@/stores/player/slices/source";
@ -22,6 +23,7 @@ export function useEmbedScraping(
embedId: string embedId: string
) { ) {
const setSource = usePlayerStore((s) => s.setSource); const setSource = usePlayerStore((s) => s.setSource);
const setCaption = usePlayerStore((s) => s.setCaption);
const setSourceId = usePlayerStore((s) => s.setSourceId); const setSourceId = usePlayerStore((s) => s.setSourceId);
const progress = usePlayerStore((s) => s.progress.time); const progress = usePlayerStore((s) => s.progress.time);
const meta = usePlayerStore((s) => s.meta); const meta = usePlayerStore((s) => s.meta);
@ -55,9 +57,14 @@ export function useEmbedScraping(
scrapeSourceOutputToProviderMetric(meta, sourceId, null, "success", null), scrapeSourceOutputToProviderMetric(meta, sourceId, null, "success", null),
]); ]);
setSourceId(sourceId); setSourceId(sourceId);
setSource(convertRunoutputToSource({ stream: result.stream }), progress); setCaption(null);
setSource(
convertRunoutputToSource({ stream: result.stream }),
convertProviderCaption(result.stream.captions),
progress
);
router.close(); router.close();
}, [embedId, sourceId, meta, router, report]); }, [embedId, sourceId, meta, router, report, setCaption]);
return { return {
run, run,
@ -69,6 +76,7 @@ export function useEmbedScraping(
export function useSourceScraping(sourceId: string | null, routerId: string) { export function useSourceScraping(sourceId: string | null, routerId: string) {
const meta = usePlayerStore((s) => s.meta); const meta = usePlayerStore((s) => s.meta);
const setSource = usePlayerStore((s) => s.setSource); const setSource = usePlayerStore((s) => s.setSource);
const setCaption = usePlayerStore((s) => s.setCaption);
const setSourceId = usePlayerStore((s) => s.setSourceId); const setSourceId = usePlayerStore((s) => s.setSourceId);
const progress = usePlayerStore((s) => s.progress.time); const progress = usePlayerStore((s) => s.progress.time);
const router = useOverlayRouter(routerId); const router = useOverlayRouter(routerId);
@ -98,7 +106,12 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
]); ]);
if (result.stream) { if (result.stream) {
setSource(convertRunoutputToSource({ stream: result.stream }), progress); setCaption(null);
setSource(
convertRunoutputToSource({ stream: result.stream }),
convertProviderCaption(result.stream.captions),
progress
);
setSourceId(sourceId); setSourceId(sourceId);
router.close(); router.close();
return null; return null;
@ -136,14 +149,16 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
), ),
]); ]);
setSourceId(sourceId); setSourceId(sourceId);
setCaption(null);
setSource( setSource(
convertRunoutputToSource({ stream: embedResult.stream }), convertRunoutputToSource({ stream: embedResult.stream }),
convertProviderCaption(embedResult.stream.captions),
progress progress
); );
router.close(); router.close();
} }
return result.embeds; return result.embeds;
}, [sourceId, meta, router]); }, [sourceId, meta, router, setCaption]);
return { return {
run, run,

View File

@ -1,7 +1,10 @@
import { RunOutput } from "@movie-web/providers";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { convert, detect, parse } from "subsrt-ts"; import { convert, detect, parse } from "subsrt-ts";
import { ContentCaption } from "subsrt-ts/dist/types/handler"; import { ContentCaption } from "subsrt-ts/dist/types/handler";
import { CaptionListItem } from "@/stores/player/slices/source";
export type CaptionCueType = ContentCaption; export type CaptionCueType = ContentCaption;
export const sanitize = DOMPurify.sanitize; export const sanitize = DOMPurify.sanitize;
@ -72,3 +75,13 @@ export function convertSubtitlesToObjectUrl(text: string): string {
}) })
); );
} }
export function convertProviderCaption(
captions: RunOutput["stream"]["captions"]
): CaptionListItem[] {
return captions.map((v) => ({
language: v.language,
url: v.url,
needsProxy: v.hasCorsRestrictions,
}));
}

View File

@ -6,6 +6,7 @@ import { useEffectOnce } from "react-use";
import { useCaptions } from "@/components/player/hooks/useCaptions"; import { useCaptions } from "@/components/player/hooks/useCaptions";
import { usePlayer } from "@/components/player/hooks/usePlayer"; import { usePlayer } from "@/components/player/hooks/usePlayer";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
import { convertProviderCaption } from "@/components/player/utils/captions";
import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource"; import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource";
import { ScrapingItems, ScrapingSegment } from "@/hooks/useProviderScrape"; import { ScrapingItems, ScrapingSegment } from "@/hooks/useProviderScrape";
import { useQueryParam } from "@/hooks/useQueryParams"; import { useQueryParam } from "@/hooks/useQueryParams";
@ -71,6 +72,7 @@ export function PlayerView() {
playMedia( playMedia(
convertRunoutputToSource(out), convertRunoutputToSource(out),
convertProviderCaption(out.stream.captions),
out.sourceId, out.sourceId,
shouldStartFromBeginning ? 0 : startAt shouldStartFromBeginning ? 0 : startAt
); );

View File

@ -52,7 +52,7 @@ export default function VideoTesterView() {
}; };
} else throw new Error("Invalid type"); } else throw new Error("Invalid type");
setMeta(testMeta); setMeta(testMeta);
playMedia(source, null); playMedia(source, [], null);
}, },
[playMedia, setMeta] [playMedia, setMeta]
); );

View File

@ -29,7 +29,6 @@ export interface ScrapingProps {
} }
export function ScrapingPart(props: ScrapingProps) { export function ScrapingPart(props: ScrapingProps) {
const { playMedia } = usePlayer();
const { report } = useReportProviders(); const { report } = useReportProviders();
const { startScraping, sourceOrder, sources, currentSource } = useScrape(); const { startScraping, sourceOrder, sources, currentSource } = useScrape();
@ -72,7 +71,7 @@ export function ScrapingPart(props: ScrapingProps) {
); );
props.onGetStream?.(output); props.onGetStream?.(output);
})(); })();
}, [startScraping, props, playMedia, report]); }, [startScraping, props, report]);
const currentProvider = sourceOrder.find( const currentProvider = sourceOrder.find(
(s) => sources[s.id].status === "pending" (s) => sources[s.id].status === "pending"

View File

@ -117,7 +117,7 @@ export function ThemePart(props: {
}) { }) {
return ( return (
<div> <div>
<Heading1 border>Appearence</Heading1> <Heading1 border>Appearance</Heading1>
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-6 max-w-[700px]"> <div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-6 max-w-[700px]">
{/* default theme */} {/* default theme */}
<ThemePreview <ThemePreview

View File

@ -1,8 +1,14 @@
import { APP_VERSION, DISCORD_LINK, GITHUB_LINK } from "./constants"; import {
APP_VERSION,
DISCORD_LINK,
DONATION_LINK,
GITHUB_LINK,
} from "./constants";
interface Config { interface Config {
APP_VERSION: string; APP_VERSION: string;
GITHUB_LINK: string; GITHUB_LINK: string;
DONATION_LINK: string;
DISCORD_LINK: string; DISCORD_LINK: string;
TMDB_READ_API_KEY: string; TMDB_READ_API_KEY: string;
CORS_PROXY_URL: string; CORS_PROXY_URL: string;
@ -13,6 +19,7 @@ interface Config {
export interface RuntimeConfig { export interface RuntimeConfig {
APP_VERSION: string; APP_VERSION: string;
GITHUB_LINK: string; GITHUB_LINK: string;
DONATION_LINK: string;
DISCORD_LINK: string; DISCORD_LINK: string;
TMDB_READ_API_KEY: string; TMDB_READ_API_KEY: string;
NORMAL_ROUTER: boolean; NORMAL_ROUTER: boolean;
@ -24,6 +31,7 @@ const env: Record<keyof Config, undefined | string> = {
TMDB_READ_API_KEY: import.meta.env.VITE_TMDB_READ_API_KEY, TMDB_READ_API_KEY: import.meta.env.VITE_TMDB_READ_API_KEY,
APP_VERSION: undefined, APP_VERSION: undefined,
GITHUB_LINK: undefined, GITHUB_LINK: undefined,
DONATION_LINK: undefined,
DISCORD_LINK: undefined, DISCORD_LINK: undefined,
CORS_PROXY_URL: import.meta.env.VITE_CORS_PROXY_URL, CORS_PROXY_URL: import.meta.env.VITE_CORS_PROXY_URL,
NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER, NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER,
@ -46,6 +54,7 @@ export function conf(): RuntimeConfig {
return { return {
APP_VERSION, APP_VERSION,
GITHUB_LINK, GITHUB_LINK,
DONATION_LINK,
DISCORD_LINK, DISCORD_LINK,
BACKEND_URL: getKey("BACKEND_URL"), BACKEND_URL: getKey("BACKEND_URL"),
TMDB_READ_API_KEY: getKey("TMDB_READ_API_KEY"), TMDB_READ_API_KEY: getKey("TMDB_READ_API_KEY"),

View File

@ -1,6 +1,5 @@
export const APP_VERSION = import.meta.env.PACKAGE_VERSION; export const APP_VERSION = import.meta.env.PACKAGE_VERSION;
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb"; export const DISCORD_LINK = "https://discord.movie-web.app";
export const GITHUB_LINK = "https://github.com/movie-web/movie-web"; export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
export const DONATION_LINK = "https://ko-fi.com/movieweb";
export const GA_ID = "G-44YVXRL61C"; export const GA_ID = "G-44YVXRL61C";
export const SENTRY_DSN =
"https://b267ab7d52674c23af4e4e6cf2956251@o4505053491167232.ingest.sentry.io/4505053495296000";

View File

@ -47,19 +47,30 @@ export interface Caption {
srtData: string; srtData: string;
} }
export interface CaptionListItem {
language: string;
url: string;
needsProxy: boolean;
}
export interface SourceSlice { export interface SourceSlice {
status: PlayerStatus; status: PlayerStatus;
source: SourceSliceSource | null; source: SourceSliceSource | null;
sourceId: string | null; sourceId: string | null;
qualities: SourceQuality[]; qualities: SourceQuality[];
currentQuality: SourceQuality | null; currentQuality: SourceQuality | null;
captionList: CaptionListItem[];
caption: { caption: {
selected: Caption | null; selected: Caption | null;
asTrack: boolean; asTrack: boolean;
}; };
meta: PlayerMeta | null; meta: PlayerMeta | null;
setStatus(status: PlayerStatus): void; setStatus(status: PlayerStatus): void;
setSource(stream: SourceSliceSource, startAt: number): void; setSource(
stream: SourceSliceSource,
captions: CaptionListItem[],
startAt: number
): void;
switchQuality(quality: SourceQuality): void; switchQuality(quality: SourceQuality): void;
setMeta(meta: PlayerMeta, status?: PlayerStatus): void; setMeta(meta: PlayerMeta, status?: PlayerStatus): void;
setCaption(caption: Caption | null): void; setCaption(caption: Caption | null): void;
@ -95,6 +106,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
source: null, source: null,
sourceId: null, sourceId: null,
qualities: [], qualities: [],
captionList: [],
currentQuality: null, currentQuality: null,
status: playerStatus.IDLE, status: playerStatus.IDLE,
meta: null, meta: null,
@ -124,7 +136,11 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
s.caption.selected = caption; s.caption.selected = caption;
}); });
}, },
setSource(stream: SourceSliceSource, startAt: number) { setSource(
stream: SourceSliceSource,
captions: CaptionListItem[],
startAt: number
) {
let qualities: string[] = []; let qualities: string[] = [];
if (stream.type === "file") qualities = Object.keys(stream.qualities); if (stream.type === "file") qualities = Object.keys(stream.qualities);
const qualityPreferences = useQualityStore.getState(); const qualityPreferences = useQualityStore.getState();
@ -134,6 +150,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
s.source = stream; s.source = stream;
s.qualities = qualities as SourceQuality[]; s.qualities = qualities as SourceQuality[];
s.currentQuality = loadableStream.quality; s.currentQuality = loadableStream.quality;
s.captionList = captions;
}); });
const store = get(); const store = get();
store.redisplaySource(startAt); store.redisplaySource(startAt);

View File

@ -56,6 +56,7 @@ export const defaultTheme = {
dimmed: "#926CAD", dimmed: "#926CAD",
divider: "#262632", divider: "#262632",
secondary: "#64647B", secondary: "#64647B",
danger: "#F46E6E"
}, },
// search bar // search bar
@ -88,7 +89,13 @@ export const defaultTheme = {
// Dropdown // Dropdown
dropdown: { dropdown: {
background: "#171728", background: "#171728",
altBackground: "#151525",
highlight: "#FCEC61",
highlightHover: "#FCEC61",
text: "#846D95",
secondary: "#73739D", secondary: "#73739D",
border: "#272742",
contentBackground: "#232337"
}, },
// Passphrase // Passphrase