From fa990d16b2dabedbece8e9c5e132dc3a5b68bc49 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 19 Nov 2023 16:49:17 +0100 Subject: [PATCH] linked captions + primary navigation dropdown Co-authored-by: Jip Frijlink --- package.json | 4 +- pnpm-lock.yaml | 64 ++------ src/backend/helpers/subs.ts | 139 ++++------------ src/components/Icon.tsx | 8 + src/components/LinksDropdown.tsx | 152 ++++++++++++++++++ src/components/layout/Navigation.tsx | 15 +- .../player/atoms/settings/CaptionsView.tsx | 133 +++++++-------- src/components/player/hooks/useCaptions.ts | 78 ++------- src/components/player/hooks/usePlayer.ts | 6 +- .../player/hooks/useSourceSelection.ts | 23 ++- src/components/player/utils/captions.ts | 13 ++ src/pages/PlayerView.tsx | 2 + src/pages/developer/VideoTesterView.tsx | 2 +- src/pages/parts/player/ScrapingPart.tsx | 3 +- src/pages/settings/ThemePart.tsx | 2 +- src/setup/config.ts | 11 +- src/setup/constants.ts | 5 +- src/stores/player/slices/source.ts | 21 ++- themes/default.ts | 7 + 19 files changed, 361 insertions(+), 327 deletions(-) create mode 100644 src/components/LinksDropdown.tsx diff --git a/package.json b/package.json index 4e3634d2..c5c8eebb 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 587bf9a0..254f77cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/backend/helpers/subs.ts b/src/backend/helpers/subs.ts index 37800546..b39a0ced 100644 --- a/src/backend/helpers/subs.ts +++ b/src/backend/helpers/subs.ts @@ -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 { - const data = await request({ - document: subtitleSearchQuery, - url: GQL_API, - variables: { - tmdb_id: meta.tmdbId, - ep: meta.episode?.number, - season: meta.season?.number, - }, - }); - - const sortedByLanguage: Record = {}; - data.subtitleSearch.data.forEach((v) => { - if (!sortedByLanguage[v.attributes.language]) - sortedByLanguage[v.attributes.language] = []; - sortedByLanguage[v.attributes.language].push(v); - }); - - return Object.values(sortedByLanguage).map((langs) => { - const 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 { - // 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( - `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(); +downloadCache.setCompare((a, b) => a === b); +const expirySeconds = 24 * 60 * 60; + +/** + * Always returns SRT + */ +export async function downloadCaption( + caption: CaptionListItem +): Promise { + const cached = downloadCache.get(caption.url); + if (cached) return cached; + + let data: string | undefined; + if (caption.needsProxy) { + data = await proxiedFetch(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; +} diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 4f81ad41..c23d3047 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -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 = { copy: ``, user: ``, up_down_arrow: ``, + rising_star: ``, + settings: ``, + coins: ``, + logout: ``, }; function ChromeCastButton() { diff --git a/src/components/LinksDropdown.tsx b/src/components/LinksDropdown.tsx new file mode 100644 index 00000000..9f74517c --- /dev/null +++ b/src/components/LinksDropdown.tsx @@ -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
; +} + +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 ( + { + evt.preventDefault(); + if (props.href) goTo(props.href); + else props.onClick?.(); + }} + className={props.className} + > + {props.children} + + ); +} + +function DropdownLink(props: { + children: React.ReactNode; + href?: string; + icon?: Icons; + highlight?: boolean; + className?: string; + onClick?: () => void; +}) { + return ( + + {props.icon ? : null} + {props.children} + + ); +} + +function CircleDropdownLink(props: { icon: Icons; href: string }) { + return ( + + + + ); +} + +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 ( +
+
+ {props.children} +
+ +
+ {userId ? ( + + + {userId} + + ) : ( + + Sync to cloud + + )} + + + Settings + + + About us + + + HELP MEEE + + {userId ? ( + + Log out + + ) : null} + +
+ + + +
+
+
+
+ ); +} diff --git a/src/components/layout/Navigation.tsx b/src/components/layout/Navigation.tsx index 842b89f3..237e6899 100644 --- a/src/components/layout/Navigation.tsx +++ b/src/components/layout/Navigation.tsx @@ -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) { ) : null}
{props.doBackground ? ( - +
+ +
) : null}
-
{loggedIn ? :

Not logged in

}
+
+ + {loggedIn ? :

Not logged in

} +
+
diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index ccf2c519..aab0d20c 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -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 =

loading...

; - else if (req.error) content =

errored!

; - 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 ( - - startDownload( - v.attributes.legacy_subtitle_id, - v.attributes.language - ) - } - > - {v.languageName} - - ); - }); - } + const content = subtitleList.map((v) => { + return ( + startDownload(v.language)} + > + {v.languageName} + + ); + }); return ( <> @@ -186,10 +164,7 @@ export function CaptionsView({ id }: { id: string }) { - + disable()} selected={!lang}> Off diff --git a/src/components/player/hooks/useCaptions.ts b/src/components/player/hooks/useCaptions.ts index 6d5635f2..522b22be 100644 --- a/src/components/player/hooks/useCaptions.ts +++ b/src/components/player/hooks/useCaptions.ts @@ -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(); -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, }; } diff --git a/src/components/player/hooks/usePlayer.ts b/src/components/player/hooks/usePlayer.ts index f6594044..ffc41067 100644 --- a/src/components/player/hooks/usePlayer.ts +++ b/src/components/player/hooks/usePlayer.ts @@ -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(); diff --git a/src/components/player/hooks/useSourceSelection.ts b/src/components/player/hooks/useSourceSelection.ts index cbf11123..094dd458 100644 --- a/src/components/player/hooks/useSourceSelection.ts +++ b/src/components/player/hooks/useSourceSelection.ts @@ -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, diff --git a/src/components/player/utils/captions.ts b/src/components/player/utils/captions.ts index 2ba822f9..0593d025 100644 --- a/src/components/player/utils/captions.ts +++ b/src/components/player/utils/captions.ts @@ -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, + })); +} diff --git a/src/pages/PlayerView.tsx b/src/pages/PlayerView.tsx index c4b76e34..37784759 100644 --- a/src/pages/PlayerView.tsx +++ b/src/pages/PlayerView.tsx @@ -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 ); diff --git a/src/pages/developer/VideoTesterView.tsx b/src/pages/developer/VideoTesterView.tsx index 258b4615..53a0c6af 100644 --- a/src/pages/developer/VideoTesterView.tsx +++ b/src/pages/developer/VideoTesterView.tsx @@ -52,7 +52,7 @@ export default function VideoTesterView() { }; } else throw new Error("Invalid type"); setMeta(testMeta); - playMedia(source, null); + playMedia(source, [], null); }, [playMedia, setMeta] ); diff --git a/src/pages/parts/player/ScrapingPart.tsx b/src/pages/parts/player/ScrapingPart.tsx index 06f69a15..57061f94 100644 --- a/src/pages/parts/player/ScrapingPart.tsx +++ b/src/pages/parts/player/ScrapingPart.tsx @@ -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" diff --git a/src/pages/settings/ThemePart.tsx b/src/pages/settings/ThemePart.tsx index eb982dd7..01829041 100644 --- a/src/pages/settings/ThemePart.tsx +++ b/src/pages/settings/ThemePart.tsx @@ -117,7 +117,7 @@ export function ThemePart(props: { }) { return (
- Appearence + Appearance
{/* default theme */} = { 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"), diff --git a/src/setup/constants.ts b/src/setup/constants.ts index f8a1cc59..e688eeb5 100644 --- a/src/setup/constants.ts +++ b/src/setup/constants.ts @@ -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"; diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 9cc5298c..9f1ea9a3 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -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 = (set, get) => ({ source: null, sourceId: null, qualities: [], + captionList: [], currentQuality: null, status: playerStatus.IDLE, meta: null, @@ -124,7 +136,11 @@ export const createSourceSlice: MakeSlice = (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 = (set, get) => ({ s.source = stream; s.qualities = qualities as SourceQuality[]; s.currentQuality = loadableStream.quality; + s.captionList = captions; }); const store = get(); store.redisplaySource(startAt); diff --git a/themes/default.ts b/themes/default.ts index f2ebd65c..0aee35d9 100644 --- a/themes/default.ts +++ b/themes/default.ts @@ -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