mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-20 14:37:43 +01:00
linked captions + primary navigation dropdown
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
parent
9152ad7bb0
commit
fa990d16b2
19 changed files with 361 additions and 327 deletions
|
@ -6,7 +6,7 @@
|
|||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.7.0",
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"@movie-web/providers": "^1.0.5",
|
||||
"@movie-web/providers": "^1.1.2",
|
||||
"@noble/hashes": "^1.3.2",
|
||||
"@react-spring/web": "^9.7.1",
|
||||
"@scure/bip39": "^1.2.1",
|
||||
|
@ -18,7 +18,6 @@
|
|||
"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",
|
||||
"immer": "^10.0.2",
|
||||
|
@ -35,7 +34,6 @@
|
|||
"react-use": "^17.4.0",
|
||||
"slugify": "^1.6.6",
|
||||
"subsrt-ts": "^2.1.1",
|
||||
"unzipit": "^1.4.3",
|
||||
"zustand": "^4.3.9"
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
@ -18,8 +18,8 @@ dependencies:
|
|||
specifier: ^1.5.0
|
||||
version: 1.7.17(react-dom@17.0.2)(react@17.0.2)
|
||||
'@movie-web/providers':
|
||||
specifier: ^1.0.5
|
||||
version: 1.0.5
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2
|
||||
'@noble/hashes':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2
|
||||
|
@ -53,9 +53,6 @@ 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
|
||||
|
@ -104,9 +101,6 @@ dependencies:
|
|||
subsrt-ts:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.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)
|
||||
|
@ -1802,14 +1796,6 @@ 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'}
|
||||
|
@ -1891,12 +1877,13 @@ packages:
|
|||
'@jridgewell/sourcemap-codec': 1.4.15
|
||||
dev: true
|
||||
|
||||
/@movie-web/providers@1.0.5:
|
||||
resolution: {integrity: sha512-/JnfH6LcERzU2AVJ0MKyRg2DHIyTc4cPipYW2tZ8h4OaQLRG0fujUT2isAZbA/PaffEfkzOQd+XdSwejNCqr8w==}
|
||||
/@movie-web/providers@1.1.2:
|
||||
resolution: {integrity: sha512-ZPSHBoz9WFLc6bWnRAXpefE+Vf8GNJ4xuWv5gu+uNg7dNBIMCnPqeuABlNIGxpEi68Go7zYlyx6nH/GQItgweA==}
|
||||
dependencies:
|
||||
cheerio: 1.0.0-rc.12
|
||||
crypto-js: 4.2.0
|
||||
form-data: 4.0.0
|
||||
iso-639-1: 3.1.0
|
||||
nanoid: 3.3.6
|
||||
node-fetch: 2.7.0
|
||||
unpacker: 1.0.1
|
||||
|
@ -2957,14 +2944,6 @@ packages:
|
|||
cross-spawn: 7.0.3
|
||||
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:
|
||||
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
|
||||
engines: {node: '>= 8'}
|
||||
|
@ -3964,23 +3943,6 @@ 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'}
|
||||
|
@ -4349,6 +4311,11 @@ packages:
|
|||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
dev: true
|
||||
|
||||
/iso-639-1@3.1.0:
|
||||
resolution: {integrity: sha512-rWcHp9dcNbxa5C8jA/cxFlWNFNwy5Vup0KcFvgA8sPQs9ZeJHj/Eq0Y8Yz2eL8XlWYpxw4iwh9FfTeVxyqdRMw==}
|
||||
engines: {node: '>=6.0'}
|
||||
dev: false
|
||||
|
||||
/jackspeak@2.3.1:
|
||||
resolution: {integrity: sha512-4iSY3Bh1Htv+kLhiiZunUhQ+OYXIn0ze3ulq8JeWrFKmhPAJSySV2+kdtRh2pGcCeF0s6oR8Oc+pYZynJj4t8A==}
|
||||
engines: {node: '>=14'}
|
||||
|
@ -6178,13 +6145,6 @@ 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'}
|
||||
|
@ -6226,10 +6186,6 @@ 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
|
||||
|
|
|
@ -1,116 +1,33 @@
|
|||
import { gql, request } from "graphql-request";
|
||||
import { list } from "subsrt-ts";
|
||||
import { unzip } from "unzipit";
|
||||
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { languageMap } from "@/setup/iso6391";
|
||||
import { PlayerMeta } from "@/stores/player/slices/source";
|
||||
|
||||
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;
|
||||
}
|
||||
import { convertSubtitlesToSrt } from "@/components/player/utils/captions";
|
||||
import { CaptionListItem } from "@/stores/player/slices/source";
|
||||
import { SimpleCache } from "@/utils/cache";
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -52,6 +52,10 @@ export enum Icons {
|
|||
COPY = "copy",
|
||||
USER = "user",
|
||||
UP_DOWN_ARROW = "up_down_arrow",
|
||||
RISING_STAR = "rising_star",
|
||||
SETTINGS = "settings",
|
||||
COINS = "coins",
|
||||
LOGOUT = "logout",
|
||||
}
|
||||
|
||||
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>`,
|
||||
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>`,
|
||||
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() {
|
||||
|
|
152
src/components/LinksDropdown.tsx
Normal file
152
src/components/LinksDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
|
|||
import { UserAvatar } from "@/components/Avatar";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { LinksDropdown } from "@/components/LinksDropdown";
|
||||
import { Lightbar } from "@/components/utils/Lightbar";
|
||||
import { useAuth } from "@/hooks/auth/useAuth";
|
||||
import { BlurEllipsis } from "@/pages/layouts/SubPageLayout";
|
||||
|
@ -37,7 +38,7 @@ export function Navigation(props: NavigationProps) {
|
|||
</div>
|
||||
) : null}
|
||||
<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={{
|
||||
top: `${bannerHeight}px`,
|
||||
}}
|
||||
|
@ -46,12 +47,14 @@ export function Navigation(props: NavigationProps) {
|
|||
className={classNames(
|
||||
"fixed left-0 right-0 flex items-center",
|
||||
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
|
||||
)}
|
||||
>
|
||||
{props.doBackground ? (
|
||||
<BlurEllipsis positionClass="absolute" />
|
||||
<div className="absolute w-full h-full inset-0 overflow-hidden">
|
||||
<BlurEllipsis positionClass="absolute" />
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={`${
|
||||
|
@ -82,7 +85,11 @@ export function Navigation(props: NavigationProps) {
|
|||
<IconPatch icon={Icons.GITHUB} clickable downsized />
|
||||
</a>
|
||||
</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>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import Fuse from "fuse.js";
|
||||
import { ReactNode, useRef, useState } from "react";
|
||||
import { useAsync, useAsyncFn } from "react-use";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useAsyncFn } from "react-use";
|
||||
import { convert } from "subsrt-ts";
|
||||
|
||||
import { SubtitleSearchItem, subtitleTypeList } from "@/backend/helpers/subs";
|
||||
import { subtitleTypeList } from "@/backend/helpers/subs";
|
||||
import { FlagIcon } from "@/components/FlagIcon";
|
||||
import { useCaptions } from "@/components/player/hooks/useCaptions";
|
||||
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 { getLanguageFromIETF } from "@/components/player/utils/language";
|
||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||
import { CaptionListItem } from "@/stores/player/slices/source";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { useSubtitleStore } from "@/stores/subtitles";
|
||||
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() {
|
||||
const lang = usePlayerStore((s) => s.caption.selected?.language);
|
||||
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 }) {
|
||||
const router = useOverlayRouter(id);
|
||||
const lang = usePlayerStore((s) => s.caption.selected?.language);
|
||||
const [currentlyDownloading, setCurrentlyDownloading] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const { search, download, disable } = useCaptions();
|
||||
const { selectLanguage, disable } = useCaptions();
|
||||
const captionList = usePlayerStore((s) => s.captionList);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const req = useAsync(async () => search(), [search]);
|
||||
const subtitleList = useSubtitleList(captionList, searchQuery);
|
||||
|
||||
const [downloadReq, startDownload] = useAsyncFn(
|
||||
async (subtitleId: string, language: string) => {
|
||||
setCurrentlyDownloading(subtitleId);
|
||||
return download(subtitleId, language);
|
||||
async (language: string) => {
|
||||
setCurrentlyDownloading(language);
|
||||
return selectLanguage(language);
|
||||
},
|
||||
[download, setCurrentlyDownloading]
|
||||
[selectLanguage, setCurrentlyDownloading]
|
||||
);
|
||||
|
||||
let content: ReactNode = null;
|
||||
if (req.loading) content = <p>loading...</p>;
|
||||
else if (req.error) content = <p>errored!</p>;
|
||||
else if (req.value) {
|
||||
const subs = req.value.filter(Boolean).map((v) => {
|
||||
const languageName =
|
||||
getLanguageFromIETF(v.attributes.language) ?? "unknown";
|
||||
return {
|
||||
...v,
|
||||
languageName,
|
||||
};
|
||||
});
|
||||
|
||||
content = searchSubs(subs, searchQuery).map((v) => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
});
|
||||
}
|
||||
const content = subtitleList.map((v) => {
|
||||
return (
|
||||
<CaptionOption
|
||||
key={v.language}
|
||||
countryCode={v.language}
|
||||
selected={lang === v.language}
|
||||
loading={v.language === currentlyDownloading && downloadReq.loading}
|
||||
error={
|
||||
v.language === currentlyDownloading && downloadReq.error
|
||||
? downloadReq.error
|
||||
: undefined
|
||||
}
|
||||
onClick={() => startDownload(v.language)}
|
||||
>
|
||||
{v.languageName}
|
||||
</CaptionOption>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -186,10 +164,7 @@ export function CaptionsView({ id }: { id: string }) {
|
|||
<Input value={searchQuery} onInput={setSearchQuery} />
|
||||
</div>
|
||||
</div>
|
||||
<Menu.ScrollToActiveSection
|
||||
loaded={req.loading}
|
||||
className="!pt-1 mt-2 pb-3"
|
||||
>
|
||||
<Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3">
|
||||
<CaptionOption onClick={() => disable()} selected={!lang}>
|
||||
Off
|
||||
</CaptionOption>
|
||||
|
|
|
@ -1,95 +1,51 @@
|
|||
import { useCallback } from "react";
|
||||
|
||||
import {
|
||||
SubtitleSearchItem,
|
||||
downloadSrt,
|
||||
searchSubtitles,
|
||||
} from "@/backend/helpers/subs";
|
||||
import { downloadCaption } from "@/backend/helpers/subs";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
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() {
|
||||
const setLanguage = useSubtitleStore((s) => s.setLanguage);
|
||||
const enabled = useSubtitleStore((s) => s.enabled);
|
||||
const setCaption = usePlayerStore((s) => s.setCaption);
|
||||
const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage);
|
||||
const meta = usePlayerStore((s) => s.meta);
|
||||
const captionList = usePlayerStore((s) => s.captionList);
|
||||
|
||||
const download = useCallback(
|
||||
async (subtitleId: string, language: string) => {
|
||||
let srtData = downloadCache.get(subtitleId);
|
||||
if (!srtData) {
|
||||
srtData = await downloadSrt(subtitleId);
|
||||
downloadCache.set(subtitleId, srtData, cacheTimeSec);
|
||||
}
|
||||
const selectLanguage = useCallback(
|
||||
async (language: string) => {
|
||||
const caption = captionList.find((v) => v.language === language);
|
||||
if (!caption) return;
|
||||
const srtData = await downloadCaption(caption);
|
||||
setCaption({
|
||||
language,
|
||||
language: caption.language,
|
||||
srtData,
|
||||
url: "", // TODO remove url
|
||||
url: caption.url,
|
||||
});
|
||||
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 () => {
|
||||
setCaption(null);
|
||||
setLanguage(null);
|
||||
}, [setCaption, setLanguage]);
|
||||
|
||||
const downloadLastUsed = useCallback(async () => {
|
||||
const selectLastUsedLanguage = useCallback(async () => {
|
||||
const language = lastSelectedLanguage ?? "en";
|
||||
const searchResult = await search();
|
||||
const languageResult = searchResult.find(
|
||||
(v) => v.attributes.language === language
|
||||
);
|
||||
if (!languageResult) return false;
|
||||
await download(
|
||||
languageResult.attributes.legacy_subtitle_id,
|
||||
languageResult.attributes.language
|
||||
);
|
||||
await selectLanguage(language);
|
||||
return true;
|
||||
}, [lastSelectedLanguage, search, download]);
|
||||
}, [lastSelectedLanguage, selectLanguage]);
|
||||
|
||||
const toggleLastUsed = useCallback(async () => {
|
||||
if (enabled) disable();
|
||||
else await downloadLastUsed();
|
||||
}, [downloadLastUsed, disable, enabled]);
|
||||
else await selectLastUsedLanguage();
|
||||
}, [selectLastUsedLanguage, disable, enabled]);
|
||||
|
||||
return {
|
||||
download,
|
||||
search,
|
||||
selectLanguage,
|
||||
disable,
|
||||
downloadLastUsed,
|
||||
selectLastUsedLanguage,
|
||||
toggleLastUsed,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useInitializePlayer } from "@/components/player/hooks/useInitializePlayer";
|
||||
import {
|
||||
CaptionListItem,
|
||||
PlayerMeta,
|
||||
PlayerStatus,
|
||||
playerStatus,
|
||||
|
@ -33,6 +34,7 @@ export function usePlayer() {
|
|||
const setStatus = usePlayerStore((s) => s.setStatus);
|
||||
const setMeta = usePlayerStore((s) => s.setMeta);
|
||||
const setSource = usePlayerStore((s) => s.setSource);
|
||||
const setCaption = usePlayerStore((s) => s.setCaption);
|
||||
const setSourceId = usePlayerStore((s) => s.setSourceId);
|
||||
const status = usePlayerStore((s) => s.status);
|
||||
const shouldStartFromBeginning = usePlayerStore(
|
||||
|
@ -57,11 +59,13 @@ export function usePlayer() {
|
|||
},
|
||||
playMedia(
|
||||
source: SourceSliceSource,
|
||||
captions: CaptionListItem[],
|
||||
sourceId: string | null,
|
||||
startAtOverride?: number
|
||||
) {
|
||||
const start = startAtOverride ?? getProgress(progressStore.items, meta);
|
||||
setSource(source, start);
|
||||
setCaption(null);
|
||||
setSource(source, captions, start);
|
||||
setSourceId(sourceId);
|
||||
setStatus(playerStatus.PLAYING);
|
||||
init();
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
scrapeSourceOutputToProviderMetric,
|
||||
useReportProviders,
|
||||
} from "@/backend/helpers/report";
|
||||
import { convertProviderCaption } from "@/components/player/utils/captions";
|
||||
import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource";
|
||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||
import { metaToScrapeMedia } from "@/stores/player/slices/source";
|
||||
|
@ -22,6 +23,7 @@ export function useEmbedScraping(
|
|||
embedId: string
|
||||
) {
|
||||
const setSource = usePlayerStore((s) => s.setSource);
|
||||
const setCaption = usePlayerStore((s) => s.setCaption);
|
||||
const setSourceId = usePlayerStore((s) => s.setSourceId);
|
||||
const progress = usePlayerStore((s) => s.progress.time);
|
||||
const meta = usePlayerStore((s) => s.meta);
|
||||
|
@ -55,9 +57,14 @@ export function useEmbedScraping(
|
|||
scrapeSourceOutputToProviderMetric(meta, sourceId, null, "success", null),
|
||||
]);
|
||||
setSourceId(sourceId);
|
||||
setSource(convertRunoutputToSource({ stream: result.stream }), progress);
|
||||
setCaption(null);
|
||||
setSource(
|
||||
convertRunoutputToSource({ stream: result.stream }),
|
||||
convertProviderCaption(result.stream.captions),
|
||||
progress
|
||||
);
|
||||
router.close();
|
||||
}, [embedId, sourceId, meta, router, report]);
|
||||
}, [embedId, sourceId, meta, router, report, setCaption]);
|
||||
|
||||
return {
|
||||
run,
|
||||
|
@ -69,6 +76,7 @@ export function useEmbedScraping(
|
|||
export function useSourceScraping(sourceId: string | null, routerId: string) {
|
||||
const meta = usePlayerStore((s) => s.meta);
|
||||
const setSource = usePlayerStore((s) => s.setSource);
|
||||
const setCaption = usePlayerStore((s) => s.setCaption);
|
||||
const setSourceId = usePlayerStore((s) => s.setSourceId);
|
||||
const progress = usePlayerStore((s) => s.progress.time);
|
||||
const router = useOverlayRouter(routerId);
|
||||
|
@ -98,7 +106,12 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
|
|||
]);
|
||||
|
||||
if (result.stream) {
|
||||
setSource(convertRunoutputToSource({ stream: result.stream }), progress);
|
||||
setCaption(null);
|
||||
setSource(
|
||||
convertRunoutputToSource({ stream: result.stream }),
|
||||
convertProviderCaption(result.stream.captions),
|
||||
progress
|
||||
);
|
||||
setSourceId(sourceId);
|
||||
router.close();
|
||||
return null;
|
||||
|
@ -136,14 +149,16 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
|
|||
),
|
||||
]);
|
||||
setSourceId(sourceId);
|
||||
setCaption(null);
|
||||
setSource(
|
||||
convertRunoutputToSource({ stream: embedResult.stream }),
|
||||
convertProviderCaption(embedResult.stream.captions),
|
||||
progress
|
||||
);
|
||||
router.close();
|
||||
}
|
||||
return result.embeds;
|
||||
}, [sourceId, meta, router]);
|
||||
}, [sourceId, meta, router, setCaption]);
|
||||
|
||||
return {
|
||||
run,
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import { RunOutput } from "@movie-web/providers";
|
||||
import DOMPurify from "dompurify";
|
||||
import { convert, detect, parse } from "subsrt-ts";
|
||||
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||
|
||||
import { CaptionListItem } from "@/stores/player/slices/source";
|
||||
|
||||
export type CaptionCueType = ContentCaption;
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import { useEffectOnce } from "react-use";
|
|||
import { useCaptions } from "@/components/player/hooks/useCaptions";
|
||||
import { usePlayer } from "@/components/player/hooks/usePlayer";
|
||||
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
|
||||
import { convertProviderCaption } from "@/components/player/utils/captions";
|
||||
import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource";
|
||||
import { ScrapingItems, ScrapingSegment } from "@/hooks/useProviderScrape";
|
||||
import { useQueryParam } from "@/hooks/useQueryParams";
|
||||
|
@ -71,6 +72,7 @@ export function PlayerView() {
|
|||
|
||||
playMedia(
|
||||
convertRunoutputToSource(out),
|
||||
convertProviderCaption(out.stream.captions),
|
||||
out.sourceId,
|
||||
shouldStartFromBeginning ? 0 : startAt
|
||||
);
|
||||
|
|
|
@ -52,7 +52,7 @@ export default function VideoTesterView() {
|
|||
};
|
||||
} else throw new Error("Invalid type");
|
||||
setMeta(testMeta);
|
||||
playMedia(source, null);
|
||||
playMedia(source, [], null);
|
||||
},
|
||||
[playMedia, setMeta]
|
||||
);
|
||||
|
|
|
@ -29,7 +29,6 @@ export interface ScrapingProps {
|
|||
}
|
||||
|
||||
export function ScrapingPart(props: ScrapingProps) {
|
||||
const { playMedia } = usePlayer();
|
||||
const { report } = useReportProviders();
|
||||
const { startScraping, sourceOrder, sources, currentSource } = useScrape();
|
||||
|
||||
|
@ -72,7 +71,7 @@ export function ScrapingPart(props: ScrapingProps) {
|
|||
);
|
||||
props.onGetStream?.(output);
|
||||
})();
|
||||
}, [startScraping, props, playMedia, report]);
|
||||
}, [startScraping, props, report]);
|
||||
|
||||
const currentProvider = sourceOrder.find(
|
||||
(s) => sources[s.id].status === "pending"
|
||||
|
|
|
@ -117,7 +117,7 @@ export function ThemePart(props: {
|
|||
}) {
|
||||
return (
|
||||
<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]">
|
||||
{/* default theme */}
|
||||
<ThemePreview
|
||||
|
|
|
@ -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 {
|
||||
APP_VERSION: string;
|
||||
GITHUB_LINK: string;
|
||||
DONATION_LINK: string;
|
||||
DISCORD_LINK: string;
|
||||
TMDB_READ_API_KEY: string;
|
||||
CORS_PROXY_URL: string;
|
||||
|
@ -13,6 +19,7 @@ interface Config {
|
|||
export interface RuntimeConfig {
|
||||
APP_VERSION: string;
|
||||
GITHUB_LINK: string;
|
||||
DONATION_LINK: string;
|
||||
DISCORD_LINK: string;
|
||||
TMDB_READ_API_KEY: string;
|
||||
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,
|
||||
APP_VERSION: undefined,
|
||||
GITHUB_LINK: undefined,
|
||||
DONATION_LINK: undefined,
|
||||
DISCORD_LINK: undefined,
|
||||
CORS_PROXY_URL: import.meta.env.VITE_CORS_PROXY_URL,
|
||||
NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER,
|
||||
|
@ -46,6 +54,7 @@ export function conf(): RuntimeConfig {
|
|||
return {
|
||||
APP_VERSION,
|
||||
GITHUB_LINK,
|
||||
DONATION_LINK,
|
||||
DISCORD_LINK,
|
||||
BACKEND_URL: getKey("BACKEND_URL"),
|
||||
TMDB_READ_API_KEY: getKey("TMDB_READ_API_KEY"),
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
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 DONATION_LINK = "https://ko-fi.com/movieweb";
|
||||
export const GA_ID = "G-44YVXRL61C";
|
||||
export const SENTRY_DSN =
|
||||
"https://b267ab7d52674c23af4e4e6cf2956251@o4505053491167232.ingest.sentry.io/4505053495296000";
|
||||
|
|
|
@ -47,19 +47,30 @@ export interface Caption {
|
|||
srtData: string;
|
||||
}
|
||||
|
||||
export interface CaptionListItem {
|
||||
language: string;
|
||||
url: string;
|
||||
needsProxy: boolean;
|
||||
}
|
||||
|
||||
export interface SourceSlice {
|
||||
status: PlayerStatus;
|
||||
source: SourceSliceSource | null;
|
||||
sourceId: string | null;
|
||||
qualities: SourceQuality[];
|
||||
currentQuality: SourceQuality | null;
|
||||
captionList: CaptionListItem[];
|
||||
caption: {
|
||||
selected: Caption | null;
|
||||
asTrack: boolean;
|
||||
};
|
||||
meta: PlayerMeta | null;
|
||||
setStatus(status: PlayerStatus): void;
|
||||
setSource(stream: SourceSliceSource, startAt: number): void;
|
||||
setSource(
|
||||
stream: SourceSliceSource,
|
||||
captions: CaptionListItem[],
|
||||
startAt: number
|
||||
): void;
|
||||
switchQuality(quality: SourceQuality): void;
|
||||
setMeta(meta: PlayerMeta, status?: PlayerStatus): void;
|
||||
setCaption(caption: Caption | null): void;
|
||||
|
@ -95,6 +106,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
|||
source: null,
|
||||
sourceId: null,
|
||||
qualities: [],
|
||||
captionList: [],
|
||||
currentQuality: null,
|
||||
status: playerStatus.IDLE,
|
||||
meta: null,
|
||||
|
@ -124,7 +136,11 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
|||
s.caption.selected = caption;
|
||||
});
|
||||
},
|
||||
setSource(stream: SourceSliceSource, startAt: number) {
|
||||
setSource(
|
||||
stream: SourceSliceSource,
|
||||
captions: CaptionListItem[],
|
||||
startAt: number
|
||||
) {
|
||||
let qualities: string[] = [];
|
||||
if (stream.type === "file") qualities = Object.keys(stream.qualities);
|
||||
const qualityPreferences = useQualityStore.getState();
|
||||
|
@ -134,6 +150,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
|||
s.source = stream;
|
||||
s.qualities = qualities as SourceQuality[];
|
||||
s.currentQuality = loadableStream.quality;
|
||||
s.captionList = captions;
|
||||
});
|
||||
const store = get();
|
||||
store.redisplaySource(startAt);
|
||||
|
|
|
@ -56,6 +56,7 @@ export const defaultTheme = {
|
|||
dimmed: "#926CAD",
|
||||
divider: "#262632",
|
||||
secondary: "#64647B",
|
||||
danger: "#F46E6E"
|
||||
},
|
||||
|
||||
// search bar
|
||||
|
@ -88,7 +89,13 @@ export const defaultTheme = {
|
|||
// Dropdown
|
||||
dropdown: {
|
||||
background: "#171728",
|
||||
altBackground: "#151525",
|
||||
highlight: "#FCEC61",
|
||||
highlightHover: "#FCEC61",
|
||||
text: "#846D95",
|
||||
secondary: "#73739D",
|
||||
border: "#272742",
|
||||
contentBackground: "#232337"
|
||||
},
|
||||
|
||||
// Passphrase
|
||||
|
|
Loading…
Reference in a new issue