diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..6c2eeb87 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_size = 2 +indent_style = space diff --git a/.eslintrc.js b/.eslintrc.js index d7dcebbb..40cda0da 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,27 +7,28 @@ const a11yOff = Object.keys(require("eslint-plugin-jsx-a11y").rules).reduce( ); module.exports = { + env: { + browser: true + }, extends: [ "airbnb", "airbnb/hooks", "plugin:@typescript-eslint/recommended", - "prettier" + "prettier", + "plugin:prettier/recommended" ], - settings: { - "import/resolver": { - typescript: {} - } - }, ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts"], parser: "@typescript-eslint/parser", parserOptions: { project: "./tsconfig.json", tsconfigRootDir: "./" }, - plugins: ["@typescript-eslint", "import"], - env: { - browser: true + settings: { + "import/resolver": { + typescript: {} + } }, + plugins: ["@typescript-eslint", "import"], rules: { "react/jsx-uses-react": "off", "react/react-in-jsx-scope": "off", @@ -47,6 +48,9 @@ module.exports = { "no-continue": "off", "no-eval": "off", "no-await-in-loop": "off", + "no-nested-ternary": "off", + "prefer-destructuring": "off", + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], "react/jsx-filename-extension": [ "error", { extensions: [".js", ".tsx", ".jsx"] } diff --git a/.github/workflows/deploying.yml b/.github/workflows/deploying.yml index 00068928..182a7895 100644 --- a/.github/workflows/deploying.yml +++ b/.github/workflows/deploying.yml @@ -31,30 +31,6 @@ jobs: name: production-files path: ./dist - deploy: - name: Deploy - needs: build - runs-on: ubuntu-latest - - steps: - - name: Download artifact - uses: actions/download-artifact@v3 - with: - name: production-files - path: ./dist - - - name: Insert config - env: - DEPLOY_CONFIG: ${{ secrets.DEPLOY_CONFIG }} - run: echo "$DEPLOY_CONFIG" > ./dist/config.js - - - name: Deploy to gh-pages - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./dist - cname: movie.squeezebox.dev - release: name: Release needs: build diff --git a/.github/workflows/linting_testing.yml b/.github/workflows/linting_testing.yml index 24d22635..e51fc630 100644 --- a/.github/workflows/linting_testing.yml +++ b/.github/workflows/linting_testing.yml @@ -5,7 +5,7 @@ on: branches: - master - dev - pull_request: + pull_request_target: types: [opened, reopened, synchronize] jobs: diff --git a/.vscode/settings.json b/.vscode/settings.json index 8ef45565..e4df0f2a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,5 @@ -{ - "files.eol": "\n", - "editor.detectIndentation": false, - "editor.tabSize": 2, - "editor.formatOnSave": true, - "editor.defaultFormatter": "dbaeumer.vscode-eslint", -} \ No newline at end of file +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint", + "eslint.format.enable": true +} diff --git a/README.md b/README.md index 58d782fb..78cbb30d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@

movie-web

-GitHub Workflow Status -GitHub license -GitHub forks -GitHub stars
+GitHub Workflow Status +GitHub license +GitHub forks +GitHub stars
Discord Server

@@ -37,7 +37,7 @@ To run this project locally for contributing or testing, run the following comma
note: must use yarn to install packages and run NodeJS 16
```bash -git clone https://github.com/JamesHawkinss/movie-web +git clone https://github.com/movie-web/movie-web cd movie-web yarn install yarn start @@ -47,10 +47,10 @@ To build production files, simply run `yarn build`. You'll need to deploy a cloudflare service worker as well. Check the [selfhosting guide](https://github.com/movie-web/movie-web/blob/dev/SELFHOSTING.md) on how to run the service worker. Afterwards you can make a `.env` file and put in the URL. (see `example.env` for an example) -

Contributing - GitHub issues -GitHub pull requests

+

Contributing - GitHub issues +GitHub pull requests

-Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/issues) for inspiration for contribution. Pull requests are always welcome. +Check out [this project's issues](https://github.com/movie-web/movie-web/issues) for inspiration for contribution. Pull requests are always welcome. **All pull requests must be merged into the `dev` branch. it will then be deployed with the next version** @@ -58,7 +58,7 @@ Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/iss This project would not be possible without our amazing contributors and the community. -GitHub contributors +GitHub contributors
@JamesHawkinss for original concept. diff --git a/index.html b/index.html index c6f4d667..7f198e1d 100644 --- a/index.html +++ b/index.html @@ -18,7 +18,7 @@ - + - + + movie-web diff --git a/package.json b/package.json index f3cfd55c..e7883b1d 100644 --- a/package.json +++ b/package.json @@ -4,19 +4,27 @@ "private": true, "homepage": "https://movie.squeezebox.dev", "dependencies": { + "@formkit/auto-animate": "^1.0.0-beta.5", "@headlessui/react": "^1.5.0", + "@types/react-helmet": "^6.1.6", "crypto-js": "^4.1.1", + "fscreen": "^1.2.0", "fuse.js": "^6.4.6", "hls.js": "^1.0.7", "i18next": "^22.4.5", "i18next-browser-languagedetector": "^7.0.1", - "i18next-http-backend": "^2.1.0", "json5": "^2.2.0", + "lodash.throttle": "^4.1.1", "nanoid": "^4.0.0", + "ofetch": "^1.0.0", + "pako": "^2.1.0", "react": "^17.0.2", "react-dom": "^17.0.2", + "react-helmet": "^6.1.0", "react-i18next": "^12.1.1", "react-router-dom": "^5.2.0", + "react-stickynode": "^4.1.0", + "react-transition-group": "^4.4.5", "srt-webvtt": "^2.0.0", "unpacker": "^1.0.1" }, @@ -25,7 +33,7 @@ "build": "vite build", "preview": "vite preview", "lint": "eslint --ext .tsx,.ts src", - "lint:strict": "eslint --ext .tsx,.ts --max-warnings 0 src", + "lint:fix": "eslint --fix --ext .tsx,.ts src", "lint:report": "eslint --ext .tsx,.ts --output-file eslint_report.json --format json src" }, "browserslist": { @@ -41,22 +49,30 @@ ] }, "devDependencies": { + "@tailwindcss/line-clamp": "^0.4.2", + "@types/chromecast-caf-sender": "^1.0.5", "@types/crypto-js": "^4.1.1", + "@types/fscreen": "^1.0.1", + "@types/lodash.throttle": "^4.1.7", "@types/node": "^17.0.15", + "@types/pako": "^2.0.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.11", "@types/react-router": "^5.1.18", "@types/react-router-dom": "^5.3.3", + "@types/react-stickynode": "^4.0.0", + "@types/react-transition-group": "^4.4.5", "@typescript-eslint/eslint-plugin": "^5.13.0", "@typescript-eslint/parser": "^5.13.0", "@vitejs/plugin-react-swc": "^3.0.0", "autoprefixer": "^10.4.13", "eslint": "^8.10.0", "eslint-config-airbnb": "19.0.4", - "eslint-config-prettier": "^8.5.0", + "eslint-config-prettier": "^8.6.0", "eslint-import-resolver-typescript": "^2.5.0", "eslint-plugin-import": "^2.25.4", "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "7.29.4", "eslint-plugin-react-hooks": "4.3.0", "postcss": "^8.4.20", @@ -65,6 +81,8 @@ "tailwind-scrollbar": "^2.0.1", "tailwindcss": "^3.2.4", "typescript": "^4.6.4", - "vite": "^4.0.1" + "vite": "^4.0.1", + "vite-plugin-checker": "^0.5.6", + "vite-plugin-package-version": "^1.0.2" } } diff --git a/prettierrc.js b/prettierrc.js new file mode 100644 index 00000000..0af34d8e --- /dev/null +++ b/prettierrc.js @@ -0,0 +1,4 @@ +module.exports = { + trailingComma: "all", + singleQuote: true +}; diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 00000000..7797f7c6 --- /dev/null +++ b/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/public/locales/en-GB/translation.json b/public/locales/en-GB/translation.json deleted file mode 100644 index f50d3c0b..00000000 --- a/public/locales/en-GB/translation.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "global": { - "name": "movie-web" - }, - "search": { - "loading": "Fetching your favourite shows...", - "providersFailed": "{{fails}}/{{total}} providers failed!", - "allResults": "That's all we have!", - "noResults": "We couldn't find anything!", - "allFailed": "All providers have failed!", - "headingTitle": "Search results", - "headingLink": "Back to home", - "bookmarks": "Bookmarks", - "continueWatching": "Continue Watching", - "tagline": "Because watching legally is boring", - "title": "What do you want to watch?", - "placeholder": "What do you want to watch?" - }, - "media": { - "invalidUrl": "Your URL may be invalid", - "arrowText": "Go back" - }, - "seasons": { - "season": "Season {{season}}", - "failed": "Failed to get season data" - }, - "notFound": { - "backArrow": "Back to home", - "media": { - "title": "Couldn't find that media", - "description": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL" - }, - "provider": { - "title": "This provider has been disabled", - "description": "We had issues with the provider or it was too unstable to use, so we had to disable it." - }, - "page": { - "title": "Couldn't find that page", - "description": "We looked everywhere: under the bins, in the closet, behind the proxy but ultimately couldn't find the page you are looking for." - } - }, - "searchBar": { - "movie": "Movie", - "series": "Series", - "Search": "Search" - }, - "errorBoundary": { - "text": "The app encountered an error and wasn't able to recover, please report it to the" - } -} diff --git a/src/backend/embeds/.gitkeep b/src/backend/embeds/.gitkeep new file mode 100644 index 00000000..f42d5aa9 --- /dev/null +++ b/src/backend/embeds/.gitkeep @@ -0,0 +1 @@ +embed scrapers go here diff --git a/src/backend/embeds/playm4u.ts b/src/backend/embeds/playm4u.ts new file mode 100644 index 00000000..8328d337 --- /dev/null +++ b/src/backend/embeds/playm4u.ts @@ -0,0 +1,19 @@ +import { MWEmbedType } from "@/backend/helpers/embed"; +import { registerEmbedScraper } from "@/backend/helpers/register"; +import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; + +registerEmbedScraper({ + id: "playm4u", + displayName: "playm4u", + for: MWEmbedType.PLAYM4U, + rank: 0, + async getStream() { + // throw new Error("Oh well 2") + return { + streamUrl: "", + quality: MWStreamQuality.Q1080P, + captions: [], + type: MWStreamType.MP4, + }; + }, +}); diff --git a/src/backend/embeds/streamm4u.ts b/src/backend/embeds/streamm4u.ts new file mode 100644 index 00000000..d0eba66a --- /dev/null +++ b/src/backend/embeds/streamm4u.ts @@ -0,0 +1,65 @@ +import { MWEmbedType } from "@/backend/helpers/embed"; +import { registerEmbedScraper } from "@/backend/helpers/register"; +import { + MWStreamQuality, + MWStreamType, + MWStream, +} from "@/backend/helpers/streams"; +import { proxiedFetch } from "@/backend/helpers/fetch"; + +const HOST = "streamm4u.club"; +const URL_BASE = `https://${HOST}`; +const URL_API = `${URL_BASE}/api`; +const URL_API_SOURCE = `${URL_API}/source`; + +async function scrape(embed: string) { + const sources: MWStream[] = []; + + const embedID = embed.split("/").pop(); + + console.log(`${URL_API_SOURCE}/${embedID}`); + const json = await proxiedFetch(`${URL_API_SOURCE}/${embedID}`, { + method: "POST", + body: `r=&d=${HOST}`, + }); + + if (json.success) { + const streams = json.data; + + for (const stream of streams) { + sources.push({ + streamUrl: stream.file as string, + quality: stream.label as MWStreamQuality, + type: stream.type as MWStreamType, + captions: [], + }); + } + } + + return sources; +} + +// TODO check out 403 / 404 on successfully returned video stream URLs +registerEmbedScraper({ + id: "streamm4u", + displayName: "streamm4u", + for: MWEmbedType.STREAMM4U, + rank: 100, + async getStream({ progress, url }) { + // const scrapingThreads = []; + // const streams = []; + + const sources = (await scrape(url)).sort( + (a, b) => + Number(b.quality.replace("p", "")) - Number(a.quality.replace("p", "")) + ); + // const preferredSourceIndex = 0; + const preferredSource = sources[0]; + + if (!preferredSource) throw new Error("No source found"); + + progress(100); + + return preferredSource; + }, +}); diff --git a/src/backend/helpers/captions.ts b/src/backend/helpers/captions.ts new file mode 100644 index 00000000..ca230fa9 --- /dev/null +++ b/src/backend/helpers/captions.ts @@ -0,0 +1,34 @@ +import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch"; +import { MWCaption, MWCaptionType } from "@/backend/helpers/streams"; +import toWebVTT from "srt-webvtt"; + +export async function getCaptionUrl(caption: MWCaption): Promise { + if (caption.type === MWCaptionType.SRT) { + let captionBlob: Blob; + + if (caption.needsProxy) { + captionBlob = await proxiedFetch(caption.url, { + responseType: "blob" as any, + }); + } else { + captionBlob = await mwFetch(caption.url, { + responseType: "blob" as any, + }); + } + + return toWebVTT(captionBlob); + } + + if (caption.type === MWCaptionType.VTT) { + if (caption.needsProxy) { + const blob = await proxiedFetch(caption.url, { + responseType: "blob" as any, + }); + return URL.createObjectURL(blob); + } + + return caption.url; + } + + throw new Error("invalid type"); +} diff --git a/src/backend/helpers/embed.ts b/src/backend/helpers/embed.ts new file mode 100644 index 00000000..64d039b7 --- /dev/null +++ b/src/backend/helpers/embed.ts @@ -0,0 +1,27 @@ +import { MWStream } from "./streams"; + +export enum MWEmbedType { + M4UFREE = "m4ufree", + STREAMM4U = "streamm4u", + PLAYM4U = "playm4u", +} + +export type MWEmbed = { + type: MWEmbedType; + url: string; +}; + +export type MWEmbedContext = { + progress(percentage: number): void; + url: string; +}; + +export type MWEmbedScraper = { + id: string; + displayName: string; + for: MWEmbedType; + rank: number; + disabled?: boolean; + + getStream(ctx: MWEmbedContext): Promise; +}; diff --git a/src/backend/helpers/fetch.ts b/src/backend/helpers/fetch.ts new file mode 100644 index 00000000..9428ab03 --- /dev/null +++ b/src/backend/helpers/fetch.ts @@ -0,0 +1,51 @@ +import { conf } from "@/setup/config"; +import { ofetch } from "ofetch"; + +type P = Parameters>; +type R = ReturnType>; + +const baseFetch = ofetch.create({ + retry: 0, +}); + +export function makeUrl(url: string, data: Record) { + let parsedUrl: string = url; + Object.entries(data).forEach(([k, v]) => { + parsedUrl = parsedUrl.replace(`{${k}}`, encodeURIComponent(v)); + }); + return parsedUrl; +} + +export function mwFetch(url: string, ops: P[1] = {}): R { + return baseFetch(url, ops); +} + +export function proxiedFetch(url: string, ops: P[1] = {}): R { + let combinedUrl = ops?.baseURL ?? ""; + if ( + combinedUrl.length > 0 && + combinedUrl.endsWith("/") && + url.startsWith("/") + ) + combinedUrl += url.slice(1); + else if ( + combinedUrl.length > 0 && + !combinedUrl.endsWith("/") && + !url.startsWith("/") + ) + combinedUrl += `/${url}`; + else combinedUrl += url; + + const parsedUrl = new URL(combinedUrl); + Object.entries(ops?.params ?? {}).forEach(([k, v]) => { + parsedUrl.searchParams.set(k, v); + }); + + return baseFetch(conf().BASE_PROXY_URL, { + ...ops, + baseURL: undefined, + params: { + destination: parsedUrl.toString(), + }, + }); +} diff --git a/src/backend/helpers/provider.ts b/src/backend/helpers/provider.ts new file mode 100644 index 00000000..95f5e374 --- /dev/null +++ b/src/backend/helpers/provider.ts @@ -0,0 +1,36 @@ +import { DetailedMeta } from "../metadata/getmeta"; +import { MWMediaType } from "../metadata/types"; +import { MWEmbed } from "./embed"; +import { MWStream } from "./streams"; + +export type MWProviderScrapeResult = { + stream?: MWStream; + embeds: MWEmbed[]; +}; + +type MWProviderBase = { + progress(percentage: number): void; + media: DetailedMeta; +}; +type MWProviderTypeSpecific = + | { + type: MWMediaType.MOVIE | MWMediaType.ANIME; + episode?: undefined; + season?: undefined; + } + | { + type: MWMediaType.SERIES; + episode: string; + season: string; + }; +export type MWProviderContext = MWProviderTypeSpecific & MWProviderBase; + +export type MWProvider = { + id: string; + displayName: string; + rank: number; + disabled?: boolean; + type: MWMediaType[]; + + scrape(ctx: MWProviderContext): Promise; +}; diff --git a/src/backend/helpers/register.ts b/src/backend/helpers/register.ts new file mode 100644 index 00000000..9d3f76c2 --- /dev/null +++ b/src/backend/helpers/register.ts @@ -0,0 +1,72 @@ +import { MWEmbedScraper, MWEmbedType } from "./embed"; +import { MWProvider } from "./provider"; + +let providers: MWProvider[] = []; +let embeds: MWEmbedScraper[] = []; + +export function registerProvider(provider: MWProvider) { + if (provider.disabled) return; + providers.push(provider); +} +export function registerEmbedScraper(embed: MWEmbedScraper) { + if (embed.disabled) return; + embeds.push(embed); +} + +export function initializeScraperStore() { + // sort by ranking + providers = providers.sort((a, b) => b.rank - a.rank); + embeds = embeds.sort((a, b) => b.rank - a.rank); + + // check for invalid ranks + let lastRank: null | number = null; + providers.forEach((v) => { + if (lastRank === null) { + lastRank = v.rank; + return; + } + if (lastRank === v.rank) + throw new Error(`Duplicate rank number for provider ${v.id}`); + lastRank = v.rank; + }); + lastRank = null; + providers.forEach((v) => { + if (lastRank === null) { + lastRank = v.rank; + return; + } + if (lastRank === v.rank) + throw new Error(`Duplicate rank number for embed scraper ${v.id}`); + lastRank = v.rank; + }); + + // check for duplicate ids + const providerIds = providers.map((v) => v.id); + if ( + providerIds.length > 0 && + new Set(providerIds).size !== providerIds.length + ) + throw new Error("Duplicate IDS in providers"); + const embedIds = embeds.map((v) => v.id); + if (embedIds.length > 0 && new Set(embedIds).size !== embedIds.length) + throw new Error("Duplicate IDS in embed scrapers"); + + // check for duplicate embed types + const embedTypes = embeds.map((v) => v.for); + if (embedTypes.length > 0 && new Set(embedTypes).size !== embedTypes.length) + throw new Error("Duplicate types in embed scrapers"); +} + +export function getProviders(): MWProvider[] { + return providers; +} + +export function getEmbeds(): MWEmbedScraper[] { + return embeds; +} + +export function getEmbedScraperByType( + type: MWEmbedType +): MWEmbedScraper | null { + return getEmbeds().find((v) => v.for === type) ?? null; +} diff --git a/src/backend/helpers/run.ts b/src/backend/helpers/run.ts new file mode 100644 index 00000000..f2f9bc9c --- /dev/null +++ b/src/backend/helpers/run.ts @@ -0,0 +1,52 @@ +import { MWEmbed, MWEmbedContext, MWEmbedScraper } from "./embed"; +import { + MWProvider, + MWProviderContext, + MWProviderScrapeResult, +} from "./provider"; +import { getEmbedScraperByType } from "./register"; +import { MWStream } from "./streams"; + +function sortProviderResult( + ctx: MWProviderScrapeResult +): MWProviderScrapeResult { + ctx.embeds = ctx.embeds + .map<[MWEmbed, MWEmbedScraper | null]>((v) => [ + v, + v.type ? getEmbedScraperByType(v.type) : null, + ]) + .sort(([, a], [, b]) => (b?.rank ?? 0) - (a?.rank ?? 0)) + .map((v) => v[0]); + return ctx; +} + +export async function runProvider( + provider: MWProvider, + ctx: MWProviderContext +): Promise { + try { + const data = await provider.scrape(ctx); + return sortProviderResult(data); + } catch (err) { + console.error("Failed to run provider", err, { + id: provider.id, + ctx: { ...ctx }, + }); + throw err; + } +} + +export async function runEmbedScraper( + scraper: MWEmbedScraper, + ctx: MWEmbedContext +): Promise { + try { + return await scraper.getStream(ctx); + } catch (err) { + console.error("Failed to run embed scraper", { + id: scraper.id, + ctx: { ...ctx }, + }); + throw err; + } +} diff --git a/src/backend/helpers/scrape.ts b/src/backend/helpers/scrape.ts new file mode 100644 index 00000000..cb160305 --- /dev/null +++ b/src/backend/helpers/scrape.ts @@ -0,0 +1,166 @@ +import { MWProviderContext, MWProviderScrapeResult } from "./provider"; +import { getEmbedScraperByType, getProviders } from "./register"; +import { runEmbedScraper, runProvider } from "./run"; +import { MWStream } from "./streams"; +import { DetailedMeta } from "../metadata/getmeta"; +import { MWMediaType } from "../metadata/types"; + +interface MWProgressData { + type: "embed" | "provider"; + id: string; + eventId: string; + percentage: number; + errored: boolean; +} +interface MWNextData { + id: string; + eventId: string; + type: "embed" | "provider"; +} + +type MWProviderRunContextBase = { + media: DetailedMeta; + onProgress?: (data: MWProgressData) => void; + onNext?: (data: MWNextData) => void; +}; +type MWProviderRunContextTypeSpecific = + | { + type: MWMediaType.MOVIE | MWMediaType.ANIME; + episode: undefined; + season: undefined; + } + | { + type: MWMediaType.SERIES; + episode: string; + season: string; + }; + +export type MWProviderRunContext = MWProviderRunContextBase & + MWProviderRunContextTypeSpecific; + +async function findBestEmbedStream( + result: MWProviderScrapeResult, + providerId: string, + ctx: MWProviderRunContext +): Promise { + if (result.stream) return result.stream; + + let embedNum = 0; + for (const embed of result.embeds) { + embedNum += 1; + if (!embed.type) continue; + const scraper = getEmbedScraperByType(embed.type); + if (!scraper) throw new Error(`Type for embed not found: ${embed.type}`); + + const eventId = [providerId, scraper.id, embedNum].join("|"); + + ctx.onNext?.({ id: scraper.id, type: "embed", eventId }); + + let stream: MWStream; + try { + stream = await runEmbedScraper(scraper, { + url: embed.url, + progress(num) { + ctx.onProgress?.({ + errored: false, + eventId, + id: scraper.id, + percentage: num, + type: "embed", + }); + }, + }); + } catch { + ctx.onProgress?.({ + errored: true, + eventId, + id: scraper.id, + percentage: 100, + type: "embed", + }); + continue; + } + + ctx.onProgress?.({ + errored: false, + eventId, + id: scraper.id, + percentage: 100, + type: "embed", + }); + + return stream; + } + + return null; +} + +export async function findBestStream( + ctx: MWProviderRunContext +): Promise { + const providers = getProviders(); + + for (const provider of providers) { + const eventId = provider.id; + ctx.onNext?.({ id: provider.id, type: "provider", eventId }); + let result: MWProviderScrapeResult; + try { + let context: MWProviderContext; + if (ctx.type === MWMediaType.SERIES) { + context = { + media: ctx.media, + type: ctx.type, + episode: ctx.episode, + season: ctx.season, + progress(num) { + ctx.onProgress?.({ + percentage: num, + eventId, + errored: false, + id: provider.id, + type: "provider", + }); + }, + }; + } else { + context = { + media: ctx.media, + type: ctx.type, + progress(num) { + ctx.onProgress?.({ + percentage: num, + eventId, + errored: false, + id: provider.id, + type: "provider", + }); + }, + }; + } + result = await runProvider(provider, context); + } catch (err) { + ctx.onProgress?.({ + percentage: 100, + errored: true, + eventId, + id: provider.id, + type: "provider", + }); + continue; + } + + ctx.onProgress?.({ + errored: false, + id: provider.id, + eventId, + percentage: 100, + type: "provider", + }); + + const stream = await findBestEmbedStream(result, provider.id, ctx); + if (!stream) continue; + return stream; + } + + return null; +} diff --git a/src/backend/helpers/streams.ts b/src/backend/helpers/streams.ts new file mode 100644 index 00000000..92943d94 --- /dev/null +++ b/src/backend/helpers/streams.ts @@ -0,0 +1,31 @@ +export enum MWStreamType { + MP4 = "mp4", + HLS = "hls", +} + +export enum MWCaptionType { + VTT = "vtt", + SRT = "srt", +} + +export enum MWStreamQuality { + Q360P = "360p", + Q480P = "480p", + Q720P = "720p", + Q1080P = "1080p", + QUNKNOWN = "unknown", +} + +export type MWCaption = { + needsProxy?: boolean; + url: string; + type: MWCaptionType; + langIso: string; +}; + +export type MWStream = { + streamUrl: string; + type: MWStreamType; + quality: MWStreamQuality; + captions: MWCaption[]; +}; diff --git a/src/backend/index.ts b/src/backend/index.ts new file mode 100644 index 00000000..7a13a445 --- /dev/null +++ b/src/backend/index.ts @@ -0,0 +1,14 @@ +import { initializeScraperStore } from "./helpers/register"; + +// providers +import "./providers/gdriveplayer"; +import "./providers/flixhq"; +import "./providers/superstream"; +import "./providers/netfilm"; +import "./providers/m4ufree"; + +// embeds +import "./embeds/streamm4u"; +import "./embeds/playm4u"; + +initializeScraperStore(); diff --git a/src/backend/metadata/getmeta.ts b/src/backend/metadata/getmeta.ts new file mode 100644 index 00000000..cb622e3b --- /dev/null +++ b/src/backend/metadata/getmeta.ts @@ -0,0 +1,80 @@ +import { FetchError } from "ofetch"; +import { makeUrl, proxiedFetch } from "../helpers/fetch"; +import { + formatJWMeta, + JWMediaResult, + JWSeasonMetaResult, + JW_API_BASE, + mediaTypeToJW, +} from "./justwatch"; +import { MWMediaMeta, MWMediaType } from "./types"; + +type JWExternalIdType = + | "eidr" + | "imdb_latest" + | "imdb" + | "tmdb_latest" + | "tmdb" + | "tms"; + +interface JWExternalId { + provider: JWExternalIdType; + external_id: string; +} + +interface JWDetailedMeta extends JWMediaResult { + external_ids: JWExternalId[]; +} + +export interface DetailedMeta { + meta: MWMediaMeta; + tmdbId: string; + imdbId: string; +} + +export async function getMetaFromId( + type: MWMediaType, + id: string, + seasonId?: string +): Promise { + const queryType = mediaTypeToJW(type); + + let data: JWDetailedMeta; + try { + const url = makeUrl("/content/titles/{type}/{id}/locale/en_US", { + type: queryType, + id, + }); + data = await proxiedFetch(url, { baseURL: JW_API_BASE }); + } catch (err) { + if (err instanceof FetchError) { + // 400 and 404 are treated as not found + if (err.statusCode === 400 || err.statusCode === 404) return null; + } + throw err; + } + + const imdbId = data.external_ids.find( + (v) => v.provider === "imdb_latest" + )?.external_id; + const tmdbId = data.external_ids.find( + (v) => v.provider === "tmdb_latest" + )?.external_id; + + if (!imdbId || !tmdbId) throw new Error("not enough info"); + + let seasonData: JWSeasonMetaResult | undefined; + if (data.object_type === "show") { + const seasonToScrape = seasonId ?? data.seasons?.[0].id.toString() ?? ""; + const url = makeUrl("/content/titles/show_season/{id}/locale/en_US", { + id: seasonToScrape, + }); + seasonData = await proxiedFetch(url, { baseURL: JW_API_BASE }); + } + + return { + meta: formatJWMeta(data, seasonData), + imdbId, + tmdbId, + }; +} diff --git a/src/backend/metadata/justwatch.ts b/src/backend/metadata/justwatch.ts new file mode 100644 index 00000000..b3ef32f7 --- /dev/null +++ b/src/backend/metadata/justwatch.ts @@ -0,0 +1,112 @@ +import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types"; + +export const JW_API_BASE = "https://apis.justwatch.com"; +export const JW_IMAGE_BASE = "https://images.justwatch.com"; + +export type JWContentTypes = "movie" | "show"; + +export type JWSeasonShort = { + title: string; + id: number; + season_number: number; +}; + +export type JWEpisodeShort = { + title: string; + id: number; + episode_number: number; +}; + +export type JWMediaResult = { + title: string; + poster?: string; + id: number; + original_release_year: number; + jw_entity_id: string; + object_type: JWContentTypes; + seasons?: JWSeasonShort[]; +}; + +export type JWSeasonMetaResult = { + title: string; + id: string; + season_number: number; + episodes: JWEpisodeShort[]; +}; + +export function mediaTypeToJW(type: MWMediaType): JWContentTypes { + if (type === MWMediaType.MOVIE) return "movie"; + if (type === MWMediaType.SERIES) return "show"; + throw new Error("unsupported type"); +} + +export function JWMediaToMediaType(type: string): MWMediaType { + if (type === "movie") return MWMediaType.MOVIE; + if (type === "show") return MWMediaType.SERIES; + throw new Error("unsupported type"); +} + +export function formatJWMeta( + media: JWMediaResult, + season?: JWSeasonMetaResult +): MWMediaMeta { + const type = JWMediaToMediaType(media.object_type); + let seasons: undefined | MWSeasonMeta[]; + if (type === MWMediaType.SERIES) { + seasons = media.seasons + ?.sort((a, b) => a.season_number - b.season_number) + .map( + (v): MWSeasonMeta => ({ + id: v.id.toString(), + number: v.season_number, + title: v.title, + }) + ); + } + + return { + title: media.title, + id: media.id.toString(), + year: media.original_release_year.toString(), + poster: media.poster + ? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}` + : undefined, + type, + seasons: seasons as any, + seasonData: season + ? ({ + id: season.id.toString(), + number: season.season_number, + title: season.title, + episodes: season.episodes + .sort((a, b) => a.episode_number - b.episode_number) + .map((v) => ({ + id: v.id.toString(), + number: v.episode_number, + title: v.title, + })), + } as any) + : (undefined as any), + }; +} + +export function JWMediaToId(media: MWMediaMeta): string { + return ["JW", mediaTypeToJW(media.type), media.id].join("-"); +} + +export function decodeJWId( + paramId: string +): { id: string; type: MWMediaType } | null { + const [prefix, type, id] = paramId.split("-", 3); + if (prefix !== "JW") return null; + let mediaType; + try { + mediaType = JWMediaToMediaType(type); + } catch { + return null; + } + return { + type: mediaType, + id, + }; +} diff --git a/src/backend/metadata/search.ts b/src/backend/metadata/search.ts new file mode 100644 index 00000000..1c3c4598 --- /dev/null +++ b/src/backend/metadata/search.ts @@ -0,0 +1,58 @@ +import { SimpleCache } from "@/utils/cache"; +import { proxiedFetch } from "../helpers/fetch"; +import { + formatJWMeta, + JWContentTypes, + JWMediaResult, + JW_API_BASE, + mediaTypeToJW, +} from "./justwatch"; +import { MWMediaMeta, MWQuery } from "./types"; + +const cache = new SimpleCache(); +cache.setCompare((a, b) => { + return a.type === b.type && a.searchQuery.trim() === b.searchQuery.trim(); +}); +cache.initialize(); + +type JWSearchQuery = { + content_types: JWContentTypes[]; + page: number; + page_size: number; + query: string; +}; + +type JWPage = { + items: T[]; + page: number; + page_size: number; + total_pages: number; + total_results: number; +}; + +export async function searchForMedia(query: MWQuery): Promise { + if (cache.has(query)) return cache.get(query) as MWMediaMeta[]; + const { searchQuery, type } = query; + + const contentType = mediaTypeToJW(type); + const body: JWSearchQuery = { + content_types: [contentType], + page: 1, + query: searchQuery, + page_size: 40, + }; + + const data = await proxiedFetch>( + "/content/titles/en_US/popular", + { + baseURL: JW_API_BASE, + params: { + body: JSON.stringify(body), + }, + } + ); + + const returnData = data.items.map((v) => formatJWMeta(v)); + cache.set(query, returnData, 3600); // cache for an hour + return returnData; +} diff --git a/src/backend/metadata/types.ts b/src/backend/metadata/types.ts new file mode 100644 index 00000000..66bb9c1a --- /dev/null +++ b/src/backend/metadata/types.ts @@ -0,0 +1,47 @@ +export enum MWMediaType { + MOVIE = "movie", + SERIES = "series", + ANIME = "anime", +} + +export type MWSeasonMeta = { + id: string; + number: number; + title: string; +}; + +export type MWSeasonWithEpisodeMeta = { + id: string; + number: number; + title: string; + episodes: { + id: string; + number: number; + title: string; + }[]; +}; + +type MWMediaMetaBase = { + title: string; + id: string; + year: string; + poster?: string; +}; + +type MWMediaMetaSpecific = + | { + type: MWMediaType.MOVIE | MWMediaType.ANIME; + seasons: undefined; + } + | { + type: MWMediaType.SERIES; + seasons: MWSeasonMeta[]; + seasonData: MWSeasonWithEpisodeMeta; + }; + +export type MWMediaMeta = MWMediaMetaBase & MWMediaMetaSpecific; + +export interface MWQuery { + searchQuery: string; + type: MWMediaType; +} diff --git a/src/backend/providers/flixhq.ts b/src/backend/providers/flixhq.ts new file mode 100644 index 00000000..fdab1292 --- /dev/null +++ b/src/backend/providers/flixhq.ts @@ -0,0 +1,66 @@ +import { compareTitle } from "@/utils/titleMatch"; +import { proxiedFetch } from "../helpers/fetch"; +import { registerProvider } from "../helpers/register"; +import { MWStreamQuality, MWStreamType } from "../helpers/streams"; +import { MWMediaType } from "../metadata/types"; + +const flixHqBase = "https://api.consumet.org/movies/flixhq"; + +registerProvider({ + id: "flixhq", + displayName: "FlixHQ", + rank: 100, + type: [MWMediaType.MOVIE], + + async scrape({ media, progress }) { + // search for relevant item + const searchResults = await proxiedFetch( + `/${encodeURIComponent(media.meta.title)}`, + { + baseURL: flixHqBase, + } + ); + const foundItem = searchResults.results.find((v: any) => { + return ( + compareTitle(v.title, media.meta.title) && + v.releaseDate === media.meta.year + ); + }); + if (!foundItem) throw new Error("No watchable item found"); + const flixId = foundItem.id; + + // get media info + progress(25); + const mediaInfo = await proxiedFetch("/info", { + baseURL: flixHqBase, + params: { + id: flixId, + }, + }); + + // get stream info from media + progress(75); + const watchInfo = await proxiedFetch("/watch", { + baseURL: flixHqBase, + params: { + episodeId: mediaInfo.episodes[0].id, + mediaId: flixId, + }, + }); + + // get best quality source + const source = watchInfo.sources.reduce((p: any, c: any) => + c.quality > p.quality ? c : p + ); + + return { + embeds: [], + stream: { + streamUrl: source.url, + quality: MWStreamQuality.QUNKNOWN, + type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4, + captions: [], + }, + }; + }, +}); diff --git a/src/providers/list/gdriveplayer/index.ts b/src/backend/providers/gdriveplayer.ts similarity index 57% rename from src/providers/list/gdriveplayer/index.ts rename to src/backend/providers/gdriveplayer.ts index d13d2414..36cc470b 100644 --- a/src/providers/list/gdriveplayer/index.ts +++ b/src/backend/providers/gdriveplayer.ts @@ -1,15 +1,10 @@ import { unpack } from "unpacker"; import CryptoJS from "crypto-js"; -import { - MWMediaProvider, - MWMediaType, - MWPortableMedia, - MWMediaStream, - MWQuery, - MWProviderMediaResult, -} from "@/providers/types"; -import { conf } from "@/config"; +import { registerProvider } from "@/backend/helpers/register"; +import { MWMediaType } from "@/backend/metadata/types"; +import { MWStreamQuality } from "@/backend/helpers/streams"; +import { proxiedFetch } from "../helpers/fetch"; const format = { stringify: (cipher: any) => { @@ -37,52 +32,23 @@ const format = { }, }; -export const gDrivePlayerScraper: MWMediaProvider = { +registerProvider({ id: "gdriveplayer", - enabled: true, - type: [MWMediaType.MOVIE], displayName: "gdriveplayer", + rank: 69, + type: [MWMediaType.MOVIE], - async getMediaFromPortable( - media: MWPortableMedia - ): Promise { - const res = await fetch( - `${conf().CORS_PROXY_URL}https://api.gdriveplayer.us/v1/imdb/${ - media.mediaId - }` - ).then((d) => d.json()); - - return { - ...media, - title: res.Title, - year: res.Year, - } as MWProviderMediaResult; - }, - - async searchForMedia(query: MWQuery): Promise { - const searchRes = await fetch( - `${ - conf().CORS_PROXY_URL - }https://api.gdriveplayer.us/v1/movie/search?title=${query.searchQuery}` - ).then((d) => d.json()); - - const results: MWProviderMediaResult[] = (searchRes || []).map( - (item: any) => ({ - title: item.title, - year: item.year, - mediaId: item.imdb, - }) + async scrape({ progress, media: { imdbId } }) { + progress(10); + const streamRes = await proxiedFetch( + "https://database.gdriveplayer.us/player.php", + { + params: { + imdb: imdbId, + }, + } ); - - return results; - }, - - async getStream(media: MWPortableMedia): Promise { - const streamRes = await fetch( - `${ - conf().CORS_PROXY_URL - }https://database.gdriveplayer.us/player.php?imdb=${media.mediaId}` - ).then((d) => d.text()); + progress(90); const page = new DOMParser().parseFromString(streamRes, "text/html"); const script: HTMLElement | undefined = Array.from( @@ -105,6 +71,7 @@ export const gDrivePlayerScraper: MWMediaProvider = { { format } ).toString(CryptoJS.enc.Utf8) ); + // eslint-disable-next-line const sources = JSON.parse( JSON.stringify( @@ -120,6 +87,18 @@ export const gDrivePlayerScraper: MWMediaProvider = { const source = sources[sources.length - 1]; /// END - return { url: `https:${source.file}`, type: source.type, captions: [] }; + let quality; + if (source.label === "720p") quality = MWStreamQuality.Q720P; + else quality = MWStreamQuality.QUNKNOWN; + + return { + stream: { + streamUrl: `https:${source.file}`, + type: source.type, + quality, + captions: [], + }, + embeds: [], + }; }, -}; +}); diff --git a/src/backend/providers/m4ufree.ts b/src/backend/providers/m4ufree.ts new file mode 100644 index 00000000..f4e79f5b --- /dev/null +++ b/src/backend/providers/m4ufree.ts @@ -0,0 +1,235 @@ +import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed"; +import { proxiedFetch } from "../helpers/fetch"; +import { registerProvider } from "../helpers/register"; +import { MWMediaType } from "../metadata/types"; + +const HOST = "m4ufree.com"; +const URL_BASE = `https://${HOST}`; +const URL_SEARCH = `${URL_BASE}/search`; +const URL_AJAX = `${URL_BASE}/ajax`; +const URL_AJAX_TV = `${URL_BASE}/ajaxtv`; + +// * Years can be in one of 4 formats: +// * - "startyear" (for movies, EX: 2022) +// * - "startyear-" (for TV series which has not ended, EX: 2022-) +// * - "startyear-endyear" (for TV series which has ended, EX: 2022-2023) +// * - "startyearendyear" (for TV series which has ended, EX: 20222023) +const REGEX_TITLE_AND_YEAR = /(.*) \(?(\d*|\d*-|\d*-\d*)\)?$/; +const REGEX_TYPE = /.*-(movie|tvshow)-online-free-m4ufree\.html/; +const REGEX_COOKIES = /XSRF-TOKEN=(.*?);.*laravel_session=(.*?);/; +const REGEX_SEASON_EPISODE = /S(\d*)-E(\d*)/; + +function toDom(html: string) { + return new DOMParser().parseFromString(html, "text/html"); +} + +registerProvider({ + id: "m4ufree", + displayName: "m4ufree", + rank: -1, + disabled: true, // Disables because the redirector URLs it returns will throw 404 / 403 depending on if you view it in the browser or fetch it respectively. It just does not work. + type: [MWMediaType.MOVIE, MWMediaType.SERIES], + + async scrape({ media, type, episode: episodeId, season: seasonId }) { + const season = + media.meta.seasons?.find((s) => s.id === seasonId)?.number || 1; + const episode = + media.meta.type === MWMediaType.SERIES + ? media.meta.seasonData.episodes.find((ep) => ep.id === episodeId) + ?.number || 1 + : undefined; + + const embeds: MWEmbed[] = []; + + /* +, { + responseType: "text" as any, + } + */ + const responseText = await proxiedFetch( + `${URL_SEARCH}/${encodeURIComponent(media.meta.title)}.html` + ); + let dom = toDom(responseText); + + const searchResults = [...dom.querySelectorAll(".item")] + .map((element) => { + const tooltipText = element.querySelector(".tiptitle p")?.innerHTML; + if (!tooltipText) return; + + let regexResult = REGEX_TITLE_AND_YEAR.exec(tooltipText); + + if (!regexResult || !regexResult[1] || !regexResult[2]) { + return; + } + + const title = regexResult[1]; + const year = Number(regexResult[2].slice(0, 4)); // * Some media stores the start AND end year. Only need start year + const a = element.querySelector("a"); + if (!a) return; + const href = a.href; + + regexResult = REGEX_TYPE.exec(href); + + if (!regexResult || !regexResult[1]) { + return; + } + + let scraperDeterminedType = regexResult[1]; + + scraperDeterminedType = + scraperDeterminedType === "tvshow" ? "show" : "movie"; // * Map to Trakt type + + return { type: scraperDeterminedType, title, year, href }; + }) + .filter((item) => item); + + const mediaInResults = searchResults.find( + (item) => + item && + item.title === media.meta.title && + item.year.toString() === media.meta.year + ); + + if (!mediaInResults) { + // * Nothing found + return { + embeds, + }; + } + + let cookies: string | null = ""; + const responseTextFromMedia = await proxiedFetch( + mediaInResults.href, + { + onResponse(context) { + cookies = context.response.headers.get("X-Set-Cookie"); + }, + } + ); + dom = toDom(responseTextFromMedia); + + let regexResult = REGEX_COOKIES.exec(cookies); + + if (!regexResult || !regexResult[1] || !regexResult[2]) { + // * DO SOMETHING? + throw new Error("No regexResults, yikesssssss kinda gross idk"); + } + + const cookieHeader = `XSRF-TOKEN=${regexResult[1]}; laravel_session=${regexResult[2]}`; + + const token = dom + .querySelector('meta[name="csrf-token"]') + ?.getAttribute("content"); + if (!token) return { embeds }; + + if (type === MWMediaType.SERIES) { + // * Get the season/episode data + const episodes = [...dom.querySelectorAll(".episode")] + .map((element) => { + regexResult = REGEX_SEASON_EPISODE.exec(element.innerHTML); + + if (!regexResult || !regexResult[1] || !regexResult[2]) { + return; + } + + const newEpisode = Number(regexResult[1]); + const newSeason = Number(regexResult[2]); + + return { + id: element.getAttribute("idepisode"), + episode: newEpisode, + season: newSeason, + }; + }) + .filter((item) => item); + + const ep = episodes.find( + (newEp) => newEp && newEp.episode === episode && newEp.season === season + ); + if (!ep) return { embeds }; + + const form = `idepisode=${ep.id}&_token=${token}`; + + const response = await proxiedFetch(URL_AJAX_TV, { + method: "POST", + headers: { + Accept: "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "en-US,en;q=0.9", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + "Sec-CH-UA": + '"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"', + "Sec-CH-UA-Mobile": "?0", + "Sec-CH-UA-Platform": '"Linux"', + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + "X-Cookie": cookieHeader, + "X-Origin": URL_BASE, + "X-Referer": mediaInResults.href, + }, + body: form, + }); + + dom = toDom(response); + } + + const servers = [...dom.querySelectorAll(".singlemv")].map((element) => + element.getAttribute("data") + ); + + for (const server of servers) { + const form = `m4u=${server}&_token=${token}`; + + const response = await proxiedFetch(URL_AJAX, { + method: "POST", + headers: { + Accept: "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "en-US,en;q=0.9", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + "Sec-CH-UA": + '"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"', + "Sec-CH-UA-Mobile": "?0", + "Sec-CH-UA-Platform": '"Linux"', + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + "X-Cookie": cookieHeader, + "X-Origin": URL_BASE, + "X-Referer": mediaInResults.href, + }, + body: form, + }); + + const serverDom = toDom(response); + + const link = serverDom.querySelector("iframe")?.src; + + const getEmbedType = (url: string) => { + if (url.startsWith("https://streamm4u.club")) + return MWEmbedType.STREAMM4U; + if (url.startsWith("https://play.playm4u.xyz")) + return MWEmbedType.PLAYM4U; + return null; + }; + + if (!link) continue; + + const embedType = getEmbedType(link); + if (embedType) { + embeds.push({ + url: link, + type: embedType, + }); + } + } + + console.log(embeds); + return { + embeds, + }; + }, +}); diff --git a/src/backend/providers/netfilm.ts b/src/backend/providers/netfilm.ts new file mode 100644 index 00000000..c33caa1b --- /dev/null +++ b/src/backend/providers/netfilm.ts @@ -0,0 +1,128 @@ +import { proxiedFetch } from "../helpers/fetch"; +import { registerProvider } from "../helpers/register"; +import { MWStreamQuality, MWStreamType } from "../helpers/streams"; +import { MWMediaType } from "../metadata/types"; + +const netfilmBase = "https://net-film.vercel.app"; + +const qualityMap = { + "360": MWStreamQuality.Q360P, + "480": MWStreamQuality.Q480P, + "720": MWStreamQuality.Q720P, + "1080": MWStreamQuality.Q1080P, +}; +type QualityInMap = keyof typeof qualityMap; + +registerProvider({ + id: "netfilm", + displayName: "NetFilm", + rank: 150, + type: [MWMediaType.MOVIE, MWMediaType.SERIES], + + async scrape({ media, episode, progress }) { + // search for relevant item + const searchResponse = await proxiedFetch( + `/api/search?keyword=${encodeURIComponent(media.meta.title)}`, + { + baseURL: netfilmBase, + } + ); + + const searchResults = searchResponse.data.results; + progress(25); + + if (media.meta.type === MWMediaType.MOVIE) { + const foundItem = searchResults.find((v: any) => { + return v.name === media.meta.title && v.releaseTime === media.meta.year; + }); + if (!foundItem) throw new Error("No watchable item found"); + const netfilmId = foundItem.id; + + // get stream info from media + progress(75); + const watchInfo = await proxiedFetch( + `/api/episode?id=${netfilmId}`, + { + baseURL: netfilmBase, + } + ); + + const { qualities } = watchInfo.data; + + // get best quality source + const source = qualities.reduce((p: any, c: any) => + c.quality > p.quality ? c : p + ); + + return { + embeds: [], + stream: { + streamUrl: source.url, + quality: qualityMap[source.quality as QualityInMap], + type: MWStreamType.HLS, + captions: [], + }, + }; + } + + if (media.meta.type !== MWMediaType.SERIES) + throw new Error("Unsupported type"); + + const desiredSeason = media.meta.seasonData.number; + + const searchItems = searchResults + .filter((v: any) => { + return v.name.includes(media.meta.title); + }) + .map((v: any) => { + return { + ...v, + season: parseInt(v.name.split(" ").at(-1), 10) || 1, + }; + }); + + const foundItem = searchItems.find((v: any) => { + return v.season === desiredSeason; + }); + + progress(50); + const seasonDetail = await proxiedFetch( + `/api/detail?id=${foundItem.id}&category=${foundItem.categoryTag[0].id}`, + { + baseURL: netfilmBase, + } + ); + + const episodeNo = media.meta.seasonData.episodes.find( + (v: any) => v.id === episode + )?.number; + const episodeData = seasonDetail.data.episodeVo.find( + (v: any) => v.seriesNo === episodeNo + ); + + progress(75); + const episodeStream = await proxiedFetch( + `/api/episode?id=${foundItem.id}&category=1&episode=${episodeData.id}`, + { + baseURL: netfilmBase, + } + ); + + const { qualities } = episodeStream.data; + + // get best quality source + const source = qualities.reduce((p: any, c: any) => + c.quality > p.quality ? c : p + ); + + return { + embeds: [], + stream: { + streamUrl: source.url, + quality: qualityMap[source.quality as QualityInMap], + type: MWStreamType.HLS, + captions: [], + }, + }; + }, +}); diff --git a/src/providers/list/superstream/LICENSE b/src/backend/providers/superstream/LICENSE similarity index 100% rename from src/providers/list/superstream/LICENSE rename to src/backend/providers/superstream/LICENSE diff --git a/src/backend/providers/superstream/index.ts b/src/backend/providers/superstream/index.ts new file mode 100644 index 00000000..8abed467 --- /dev/null +++ b/src/backend/providers/superstream/index.ts @@ -0,0 +1,249 @@ +import { registerProvider } from "@/backend/helpers/register"; +import { MWMediaType } from "@/backend/metadata/types"; + +import { customAlphabet } from "nanoid"; +import CryptoJS from "crypto-js"; +import { proxiedFetch } from "@/backend/helpers/fetch"; +import { + MWCaption, + MWCaptionType, + MWStreamQuality, + MWStreamType, +} from "@/backend/helpers/streams"; +import { compareTitle } from "@/utils/titleMatch"; + +const nanoid = customAlphabet("0123456789abcdef", 32); + +const qualityMap = { + "360p": MWStreamQuality.Q360P, + "480p": MWStreamQuality.Q480P, + "720p": MWStreamQuality.Q720P, + "1080p": MWStreamQuality.Q1080P, +}; +type QualityInMap = keyof typeof qualityMap; + +// CONSTANTS, read below (taken from og) +// We do not want content scanners to notice this scraping going on so we've hidden all constants +// The source has its origins in China so I added some extra security with banned words +// Mayhaps a tiny bit unethical, but this source is just too good :) +// If you are copying this code please use precautions so they do not change their api. +const iv = atob("d0VpcGhUbiE="); +const key = atob("MTIzZDZjZWRmNjI2ZHk1NDIzM2FhMXc2"); +const apiUrls = [ + atob("aHR0cHM6Ly9zaG93Ym94LnNoZWd1Lm5ldC9hcGkvYXBpX2NsaWVudC9pbmRleC8="), + atob("aHR0cHM6Ly9tYnBhcGkuc2hlZ3UubmV0L2FwaS9hcGlfY2xpZW50L2luZGV4Lw=="), +]; +const appKey = atob("bW92aWVib3g="); +const appId = atob("Y29tLnRkby5zaG93Ym94"); + +// cryptography stuff +const crypto = { + encrypt(str: string) { + return CryptoJS.TripleDES.encrypt(str, CryptoJS.enc.Utf8.parse(key), { + iv: CryptoJS.enc.Utf8.parse(iv), + }).toString(); + }, + getVerify(str: string, str2: string, str3: string) { + if (str) { + return CryptoJS.MD5( + CryptoJS.MD5(str2).toString() + str3 + str + ).toString(); + } + return null; + }, +}; + +// get expire time +const expiry = () => Math.floor(Date.now() / 1000 + 60 * 60 * 12); + +// sending requests +const get = (data: object, altApi = false) => { + const defaultData = { + childmode: "0", + app_version: "11.5", + appid: appId, + lang: "en", + expired_date: `${expiry()}`, + platform: "android", + channel: "Website", + }; + const encryptedData = crypto.encrypt( + JSON.stringify({ + ...defaultData, + ...data, + }) + ); + const appKeyHash = CryptoJS.MD5(appKey).toString(); + const verify = crypto.getVerify(encryptedData, appKey, key); + const body = JSON.stringify({ + app_key: appKeyHash, + verify, + encrypt_data: encryptedData, + }); + const b64Body = btoa(body); + + const formatted = new URLSearchParams(); + formatted.append("data", b64Body); + formatted.append("appid", "27"); + formatted.append("platform", "android"); + formatted.append("version", "129"); + formatted.append("medium", "Website"); + + const requestUrl = altApi ? apiUrls[1] : apiUrls[0]; + return proxiedFetch(requestUrl, { + method: "POST", + parseResponse: JSON.parse, + headers: { + Platform: "android", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: `${formatted.toString()}&token${nanoid()}`, + }); +}; + +// Find best resolution +const getBestQuality = (list: any[]) => { + return ( + list.find((quality: any) => quality.quality === "1080p" && quality.path) ?? + list.find((quality: any) => quality.quality === "720p" && quality.path) ?? + list.find((quality: any) => quality.quality === "480p" && quality.path) ?? + list.find((quality: any) => quality.quality === "360p" && quality.path) + ); +}; + +registerProvider({ + id: "superstream", + displayName: "Superstream", + rank: 200, + type: [MWMediaType.MOVIE, MWMediaType.SERIES], + + async scrape({ media, episode, progress }) { + // Find Superstream ID for show + const searchQuery = { + module: "Search3", + page: "1", + type: "all", + keyword: media.meta.title, + pagelimit: "20", + }; + const searchRes = (await get(searchQuery, true)).data; + progress(33); + + const superstreamEntry = searchRes.find( + (res: any) => + compareTitle(res.title, media.meta.title) && + res.year === Number(media.meta.year) + ); + + if (!superstreamEntry) throw new Error("No entry found on SuperStream"); + const superstreamId = superstreamEntry.id; + + // Movie logic + if (media.meta.type === MWMediaType.MOVIE) { + const apiQuery = { + uid: "", + module: "Movie_downloadurl_v3", + mid: superstreamId, + oss: "1", + group: "", + }; + + const mediaRes = (await get(apiQuery)).data; + progress(50); + + const hdQuality = getBestQuality(mediaRes.list); + + if (!hdQuality) throw new Error("No quality could be found."); + + const subtitleApiQuery = { + fid: hdQuality.fid, + uid: "", + module: "Movie_srt_list_v2", + mid: superstreamId, + }; + + const subtitleRes = (await get(subtitleApiQuery)).data; + + const mappedCaptions = subtitleRes.list.map( + (subtitle: any): MWCaption => { + return { + needsProxy: true, + langIso: subtitle.language, + url: subtitle.subtitles[0].file_path, + type: MWCaptionType.SRT, + }; + } + ); + + return { + embeds: [], + stream: { + streamUrl: hdQuality.path, + quality: qualityMap[hdQuality.quality as QualityInMap], + type: MWStreamType.MP4, + captions: mappedCaptions, + }, + }; + } + + if (media.meta.type !== MWMediaType.SERIES) + throw new Error("Unsupported type"); + + // Fetch requested episode + const apiQuery = { + uid: "", + module: "TV_downloadurl_v3", + tid: superstreamId, + season: media.meta.seasonData.number.toString(), + episode: ( + media.meta.seasonData.episodes.find( + (episodeInfo) => episodeInfo.id === episode + )?.number ?? 1 + ).toString(), + oss: "1", + group: "", + }; + + const mediaRes = (await get(apiQuery)).data; + progress(66); + + const hdQuality = getBestQuality(mediaRes.list); + + if (!hdQuality) throw new Error("No quality could be found."); + + const subtitleApiQuery = { + fid: hdQuality.fid, + uid: "", + module: "TV_srt_list_v2", + episode: + media.meta.seasonData.episodes.find( + (episodeInfo) => episodeInfo.id === episode + )?.number ?? 1, + tid: superstreamId, + season: media.meta.seasonData.number.toString(), + }; + + const subtitleRes = (await get(subtitleApiQuery)).data; + + const mappedCaptions = subtitleRes.list.map((subtitle: any): MWCaption => { + return { + needsProxy: true, + langIso: subtitle.language, + url: subtitle.subtitles[0].file_path, + type: MWCaptionType.SRT, + }; + }); + + return { + embeds: [], + stream: { + quality: qualityMap[ + hdQuality.quality as QualityInMap + ] as MWStreamQuality, + streamUrl: hdQuality.path, + type: MWStreamType.MP4, + captions: mappedCaptions, + }, + }; + }, +}); diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 00000000..9a74f84d --- /dev/null +++ b/src/components/Button.tsx @@ -0,0 +1,25 @@ +import { Icon, Icons } from "@/components/Icon"; +import { ReactNode } from "react"; + +interface Props { + icon?: Icons; + onClick?: () => void; + children?: ReactNode; +} + +export function Button(props: Props) { + return ( + + ); +} diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index a73ad138..84ab2957 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -57,5 +57,5 @@ export function Dropdown(props: DropdownProps) { )}
- ) + ); } diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 65d47891..bf0a0ae2 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -1,12 +1,16 @@ +import { memo, useEffect, useRef } from "react"; + export enum Icons { SEARCH = "search", BOOKMARK = "bookmark", + BOOKMARK_OUTLINE = "bookmark_outline", CLOCK = "clock", EYE_SLASH = "eyeSlash", ARROW_LEFT = "arrowLeft", ARROW_RIGHT = "arrowRight", CHEVRON_DOWN = "chevronDown", CHEVRON_RIGHT = "chevronRight", + CHEVRON_LEFT = "chevronLeft", CLAPPER_BOARD = "clapperBoard", FILM = "film", DRAGON = "dragon", @@ -14,6 +18,22 @@ export enum Icons { MOVIE_WEB = "movieWeb", DISCORD = "discord", GITHUB = "github", + PLAY = "play", + PAUSE = "pause", + EXPAND = "expand", + COMPRESS = "compress", + VOLUME = "volume", + VOLUME_X = "volume_x", + X = "x", + EDIT = "edit", + AIRPLAY = "airplay", + EPISODES = "episodes", + SKIP_FORWARD = "skip_forward", + SKIP_BACKWARD = "skip_backward", + FILE = "file", + CAPTIONS = "captions", + LINK = "link", + CASTING = "casting", } export interface IconProps { @@ -29,6 +49,7 @@ const iconList: Record = { arrowLeft: ``, chevronDown: ``, chevronRight: ``, + chevronLeft: ``, clapperBoard: ``, film: ``, dragon: ``, @@ -37,13 +58,46 @@ const iconList: Record = { movieWeb: ``, discord: ``, github: ``, + play: ``, + pause: ``, + expand: ``, + compress: ``, + volume: ``, + volume_x: ``, + x: ``, + edit: ``, + bookmark_outline: ``, + airplay: ``, + episodes: ``, + skip_forward: ``, + skip_backward: ``, + file: ``, + captions: ``, + link: ``, + casting: "", }; -export function Icon(props: IconProps) { +function ChromeCastButton() { + const ref = useRef(null); + + useEffect(() => { + const tag = document.createElement("google-cast-launcher"); + tag.setAttribute("id", "castbutton"); + ref.current?.appendChild(tag); + }, []); + + return
; +} + +export const Icon = memo((props: IconProps) => { + if (props.icon === Icons.CASTING) { + return ; + } + return ( ); -} +}); diff --git a/src/components/Overlay.tsx b/src/components/Overlay.tsx new file mode 100644 index 00000000..83e61f8f --- /dev/null +++ b/src/components/Overlay.tsx @@ -0,0 +1,20 @@ +import { Transition } from "@/components/Transition"; +import { Helmet } from "react-helmet"; + +export function Overlay(props: { children: React.ReactNode }) { + return ( + <> + + + +
+ + {props.children} +
+ + ); +} diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 6a5c3ad4..2b937549 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,8 +1,8 @@ +import { MWMediaType, MWQuery } from "@/backend/metadata/types"; import { useState } from "react"; -import { MWMediaType, MWQuery } from "@/providers"; import { useTranslation } from "react-i18next"; import { DropdownButton } from "./buttons/DropdownButton"; -import { Icons } from "./Icon"; +import { Icon, Icons } from "./Icon"; import { TextInputControl } from "./text-inputs/TextInputControl"; export interface SearchBarProps { @@ -37,42 +37,43 @@ export function SearchBarInput(props: SearchBarProps) { } return ( -
+
+
+ +
+ setSearch(val)} value={props.value.searchQuery} - className="w-full flex-1 bg-transparent text-white placeholder-denim-700 focus:outline-none" + className="w-full flex-1 bg-transparent px-4 py-4 pl-12 text-white placeholder-denim-700 focus:outline-none sm:py-4 sm:pr-2" placeholder={props.placeholder} /> - setDropdownOpen(val)} - selectedItem={props.value.type} - setSelectedItem={(val) => setType(val)} - options={[ - { - id: MWMediaType.MOVIE, - name: t('searchBar.movie'), - icon: Icons.FILM, - }, - { - id: MWMediaType.SERIES, - name: t('searchBar.series'), - icon: Icons.CLAPPER_BOARD, - }, - // { - // id: MWMediaType.ANIME, - // name: "Anime", - // icon: Icons.DRAGON, - // }, - ]} - onClick={() => setDropdownOpen((old) => !old)} - > - {props.buttonText || t('searchBar.search')} - +
+ setDropdownOpen(val)} + selectedItem={props.value.type} + setSelectedItem={(val) => setType(val)} + options={[ + { + id: MWMediaType.MOVIE, + name: t("searchBar.movie"), + icon: Icons.FILM, + }, + { + id: MWMediaType.SERIES, + name: t("searchBar.series"), + icon: Icons.CLAPPER_BOARD, + }, + ]} + onClick={() => setDropdownOpen((old) => !old)} + > + {props.buttonText || t("searchBar.search")} + +
); } diff --git a/src/components/Transition.tsx b/src/components/Transition.tsx new file mode 100644 index 00000000..cda3b945 --- /dev/null +++ b/src/components/Transition.tsx @@ -0,0 +1,75 @@ +import { Fragment, ReactNode } from "react"; +import { + Transition as HeadlessTransition, + TransitionClasses, +} from "@headlessui/react"; + +type TransitionAnimations = "slide-down" | "slide-up" | "fade" | "none"; + +interface Props { + show?: boolean; + durationClass?: string; + animation: TransitionAnimations; + className?: string; + children?: ReactNode; + isChild?: boolean; +} + +function getClasses( + animation: TransitionAnimations, + duration: string +): TransitionClasses { + if (animation === "slide-down") { + return { + leave: `transition-[transform,opacity] ${duration}`, + leaveFrom: "opacity-100 translate-y-0", + leaveTo: "-translate-y-4 opacity-0", + enter: `transition-[transform,opacity] ${duration}`, + enterFrom: "opacity-0 -translate-y-4", + enterTo: "translate-y-0 opacity-100", + }; + } + + if (animation === "slide-up") { + return { + leave: `transition-[transform,opacity] ${duration}`, + leaveFrom: "opacity-100 translate-y-0", + leaveTo: "translate-y-4 opacity-0", + enter: `transition-[transform,opacity] ${duration}`, + enterFrom: "opacity-0 translate-y-4", + enterTo: "translate-y-0 opacity-100", + }; + } + + if (animation === "fade") { + return { + leave: `transition-[transform,opacity] ${duration}`, + leaveFrom: "opacity-100", + leaveTo: "opacity-0", + enter: `transition-[transform,opacity] ${duration}`, + enterFrom: "opacity-0", + enterTo: "opacity-100", + }; + } + + return {}; +} + +export function Transition(props: Props) { + const duration = props.durationClass ?? "duration-200"; + const classes = getClasses(props.animation, duration); + + if (props.isChild) { + return ( + +
{props.children}
+
+ ); + } + + return ( + +
{props.children}
+
+ ); +} diff --git a/src/components/buttons/DropdownButton.tsx b/src/components/buttons/DropdownButton.tsx index 5c1a12c5..a49403e8 100644 --- a/src/components/buttons/DropdownButton.tsx +++ b/src/components/buttons/DropdownButton.tsx @@ -6,7 +6,7 @@ import React, { } from "react"; import { Icon, Icons } from "@/components/Icon"; -import { Backdrop, useBackdrop } from "@/components/layout/Backdrop"; +import { BackdropContainer, useBackdrop } from "@/components/layout/Backdrop"; import { ButtonControlProps, ButtonControl } from "./ButtonControl"; export interface OptionItem { @@ -56,7 +56,7 @@ export const DropdownButton = React.forwardRef< ); useEffect(() => { - let id: NodeJS.Timeout; + let id: ReturnType; if (props.open) { setDelayedSelectedId(props.selectedItem); @@ -93,37 +93,43 @@ export const DropdownButton = React.forwardRef< className="relative w-full sm:w-auto" {...highlightedProps} > - props.setOpen(false)} + {...backdropProps} > - - {selectedItem.name} - - -
- {props.options - .filter((opt) => opt.id !== delayedSelectedId) - .map((opt) => ( -
+ + + {selectedItem.name} + + +
+ {props.options + .filter((opt) => opt.id !== delayedSelectedId) + .map((opt) => ( +
+
- props.setOpen(false)} {...backdropProps} />
); }); diff --git a/src/components/buttons/EditButton.tsx b/src/components/buttons/EditButton.tsx new file mode 100644 index 00000000..bcdd3cfd --- /dev/null +++ b/src/components/buttons/EditButton.tsx @@ -0,0 +1,36 @@ +import { Icon, Icons } from "@/components/Icon"; +import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { ButtonControl } from "./ButtonControl"; + +export interface EditButtonProps { + editing: boolean; + onEdit?: (editing: boolean) => void; +} + +export function EditButton(props: EditButtonProps) { + const { t } = useTranslation(); + const [parent] = useAutoAnimate(); + + const onClick = useCallback(() => { + props.onEdit?.(!props.editing); + }, [props]); + + return ( + + + {props.editing ? ( + + {t("media.stopEditing")} + + ) : ( + + )} + + + ); +} diff --git a/src/components/buttons/IconPatch.tsx b/src/components/buttons/IconPatch.tsx index f14a3f56..d51f20b1 100644 --- a/src/components/buttons/IconPatch.tsx +++ b/src/components/buttons/IconPatch.tsx @@ -6,17 +6,24 @@ export interface IconPatchProps { clickable?: boolean; className?: string; icon: Icons; + transparent?: boolean; } export function IconPatch(props: IconPatchProps) { + const clickableClasses = props.clickable + ? "cursor-pointer hover:scale-110 hover:bg-denim-600 hover:text-white active:scale-125" + : ""; + const transparentClasses = props.transparent + ? "bg-opacity-0 hover:bg-opacity-50" + : ""; + const activeClasses = props.active + ? "border-bink-600 bg-bink-100 text-bink-600" + : ""; + return (
diff --git a/src/components/layout/Backdrop.tsx b/src/components/layout/Backdrop.tsx index 65d3a81d..57719e29 100644 --- a/src/components/layout/Backdrop.tsx +++ b/src/components/layout/Backdrop.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from "react"; +import React, { createRef, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; import { useFade } from "@/hooks/useFade"; interface BackdropProps { @@ -39,7 +40,7 @@ export function useBackdrop(): [ return [setBackdrop, backdropProps, highlightedProps]; } -export function Backdrop(props: BackdropProps) { +function Backdrop(props: BackdropProps) { const clickEvent = props.onClick || (() => {}); const animationEvent = props.onBackdropHide || (() => {}); const [isVisible, setVisible, fadeProps] = useFade(); @@ -58,7 +59,7 @@ export function Backdrop(props: BackdropProps) { return (
); } + +export function BackdropContainer( + props: { + children: React.ReactNode; + } & BackdropProps +) { + const root = createRef(); + const copy = createRef(); + + useEffect(() => { + let frame = -1; + function poll() { + if (root.current && copy.current) { + const rect = root.current.getBoundingClientRect(); + copy.current.style.top = `${rect.top}px`; + copy.current.style.left = `${rect.left}px`; + copy.current.style.width = `${rect.width}px`; + copy.current.style.height = `${rect.height}px`; + } + frame = window.requestAnimationFrame(poll); + } + poll(); + return () => { + window.cancelAnimationFrame(frame); + }; + // we dont want this to run only on mount, dont care about ref updates + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [root, copy]); + + return ( +
+ {createPortal( +
+ +
+ {props.children} +
+
, + document.body + )} +
{props.children}
+
+ ); +} diff --git a/src/components/layout/BrandPill.tsx b/src/components/layout/BrandPill.tsx index 3df0be76..cef38ab5 100644 --- a/src/components/layout/BrandPill.tsx +++ b/src/components/layout/BrandPill.tsx @@ -1,18 +1,19 @@ -import { Icon, Icons } from "@/components/Icon"; import { useTranslation } from "react-i18next"; +import { Icon, Icons } from "@/components/Icon"; export function BrandPill(props: { clickable?: boolean }) { const { t } = useTranslation(); return (
- {t('global.name')} + {t("global.name")}
); } diff --git a/src/components/layout/ErrorBoundary.tsx b/src/components/layout/ErrorBoundary.tsx index b1803226..bde7c11d 100644 --- a/src/components/layout/ErrorBoundary.tsx +++ b/src/components/layout/ErrorBoundary.tsx @@ -3,7 +3,65 @@ import { IconPatch } from "@/components/buttons/IconPatch"; import { Icons } from "@/components/Icon"; import { Link } from "@/components/text/Link"; import { Title } from "@/components/text/Title"; -import { conf } from "@/config"; +import { conf } from "@/setup/config"; +import { Trans, useTranslation } from "react-i18next"; + +interface ErrorShowcaseProps { + error: { + name: string; + description: string; + path: string; + }; +} + +export function ErrorShowcase(props: ErrorShowcaseProps) { + return ( +
+

+ {props.error.name} - {props.error.description} +

+

{props.error.path}

+
+ ); +} + +interface ErrorMessageProps { + error?: { + name: string; + description: string; + path: string; + }; + localSize?: boolean; + children?: React.ReactNode; +} + +export function ErrorMessage(props: ErrorMessageProps) { + const { t } = useTranslation(); + + return ( +
+
+ + {t("media.errors.genericTitle")} + {props.children ? ( +

{props.children}

+ ) : ( +

+ + + + +

+ )} +
+ {props.error ? : null} +
+ ); +} interface ErrorBoundaryState { hasError: boolean; @@ -50,33 +108,6 @@ export class ErrorBoundary extends Component< render() { if (!this.state.hasError) return this.props.children as any; - return ( -
-
- - Whoops, it broke -

- The app encountered an error and wasn't able to recover, please - report it to the{" "} - - Discord server - {" "} - or on{" "} - - GitHub - - . -

-
- {this.state.error ? ( -
-

- {this.state.error.name} - {this.state.error.description} -

-

{this.state.error.path}

-
- ) : null} -
- ); + return ; } } diff --git a/src/components/layout/Loading.tsx b/src/components/layout/Loading.tsx index 7af05dfe..cff6a503 100644 --- a/src/components/layout/Loading.tsx +++ b/src/components/layout/Loading.tsx @@ -8,10 +8,10 @@ export function Loading(props: LoadingProps) {
-
-
-
-
+
+
+
+
{props.text && props.text.length ? (

{props.text}

diff --git a/src/components/layout/Modal.tsx b/src/components/layout/Modal.tsx new file mode 100644 index 00000000..7a1c3b64 --- /dev/null +++ b/src/components/layout/Modal.tsx @@ -0,0 +1,44 @@ +import { Overlay } from "@/components/Overlay"; +import { Transition } from "@/components/Transition"; +import { ReactNode } from "react"; +import { createPortal } from "react-dom"; + +interface Props { + show: boolean; + children?: ReactNode; +} + +export function ModalFrame(props: Props) { + return ( + + + + {props.children} + + + + ); +} + +export function Modal(props: Props) { + return createPortal( + {props.children}, + document.body + ); +} + +export function ModalCard(props: { children?: ReactNode }) { + return ( +
+ {props.children} +
+ ); +} diff --git a/src/components/layout/Navigation.tsx b/src/components/layout/Navigation.tsx index 00fd2eb0..71d40f76 100644 --- a/src/components/layout/Navigation.tsx +++ b/src/components/layout/Navigation.tsx @@ -2,17 +2,25 @@ import { ReactNode } from "react"; import { Link } from "react-router-dom"; import { IconPatch } from "@/components/buttons/IconPatch"; import { Icons } from "@/components/Icon"; -import { conf } from "@/config"; +import { conf } from "@/setup/config"; import { BrandPill } from "./BrandPill"; export interface NavigationProps { children?: ReactNode; + bg?: boolean; } export function Navigation(props: NavigationProps) { return ( -
-
+
+
+
+
+
@@ -23,7 +31,7 @@ export function Navigation(props: NavigationProps) {
+
{props.children}
- ) + ); } diff --git a/src/components/layout/ProgressRing.tsx b/src/components/layout/ProgressRing.tsx new file mode 100644 index 00000000..6e3f93ac --- /dev/null +++ b/src/components/layout/ProgressRing.tsx @@ -0,0 +1,39 @@ +interface Props { + className?: string; + radius?: number; + percentage: number; + backingRingClassname?: string; +} + +export function ProgressRing(props: Props) { + const radius = props.radius ?? 40; + + return ( + + + + + ); +} diff --git a/src/components/layout/Seasons.tsx b/src/components/layout/Seasons.tsx deleted file mode 100644 index b6baab05..00000000 --- a/src/components/layout/Seasons.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { useEffect, useState } from "react"; -import { useHistory } from "react-router-dom"; -import { IconPatch } from "@/components/buttons/IconPatch"; -import { Dropdown, OptionItem } from "@/components/Dropdown"; -import { Icons } from "@/components/Icon"; -import { WatchedEpisode } from "@/components/media/WatchedEpisodeButton"; -import { useLoading } from "@/hooks/useLoading"; -import { serializePortableMedia } from "@/hooks/usePortableMedia"; -import { - convertMediaToPortable, - MWMedia, - MWMediaSeasons, - MWMediaSeason, - MWPortableMedia, -} from "@/providers"; -import { getSeasonDataFromMedia } from "@/providers/methods/seasons"; -import { useTranslation } from "react-i18next"; - -export interface SeasonsProps { - media: MWMedia; -} - -export function LoadingSeasons(props: { error?: boolean }) { - const { t } = useTranslation(); - - return ( -
-
-
-
- {!props.error ? ( - <> -
-
-
- - ) : ( -
- -

{t('seasons.failed')}

-
- )} -
- ); -} - -export function Seasons(props: SeasonsProps) { - const { t } = useTranslation(); - - const [searchSeasons, loading, error, success] = useLoading( - (portableMedia: MWPortableMedia) => getSeasonDataFromMedia(portableMedia) - ); - const history = useHistory(); - const [seasons, setSeasons] = useState({ seasons: [] }); - const seasonSelected = props.media.seasonId as string; - const episodeSelected = props.media.episodeId as string; - - useEffect(() => { - (async () => { - const seasonData = await searchSeasons(props.media); - setSeasons(seasonData); - })(); - }, [searchSeasons, props.media]); - - function navigateToSeasonAndEpisode(seasonId: string, episodeId: string) { - const newMedia: MWMedia = { ...props.media }; - newMedia.episodeId = episodeId; - newMedia.seasonId = seasonId; - history.replace( - `/media/${newMedia.mediaType}/${serializePortableMedia( - convertMediaToPortable(newMedia) - )}` - ); - } - - const mapSeason = (season: MWMediaSeason) => ({ - id: season.id, - name: season.title || `${t('seasons.season', { season: season.sort })}`, - }); - - const options = seasons.seasons.map(mapSeason); - - const foundSeason = seasons.seasons.find( - (season) => season.id === seasonSelected - ); - const selectedItem = foundSeason ? mapSeason(foundSeason) : null; - - return ( - <> - {loading ? : null} - {error ? : null} - {success && seasons.seasons.length ? ( - <> - - navigateToSeasonAndEpisode( - seasonItem.id, - seasons.seasons.find((s) => s.id === seasonItem.id)?.episodes[0] - .id as string - ) - } - /> - {seasons.seasons - .find((s) => s.id === seasonSelected) - ?.episodes.map((v) => ( - navigateToSeasonAndEpisode(seasonSelected, v.id)} - /> - ))} - - ) : null} - - ); -} diff --git a/src/components/layout/SectionHeading.tsx b/src/components/layout/SectionHeading.tsx index fd89d47a..a9d01cb7 100644 --- a/src/components/layout/SectionHeading.tsx +++ b/src/components/layout/SectionHeading.tsx @@ -1,13 +1,10 @@ import { ReactNode } from "react"; import { Icon, Icons } from "@/components/Icon"; -import { ArrowLink } from "@/components/text/ArrowLink"; interface SectionHeadingProps { icon?: Icons; title: string; children?: ReactNode; - linkText?: string; - onClick?: () => void; className?: string; } @@ -23,15 +20,8 @@ export function SectionHeading(props: SectionHeadingProps) { ) : null} {props.title}

- {props.linkText ? ( - - ) : null} + {props.children}
- {props.children}
); } diff --git a/src/components/layout/Spinner.css b/src/components/layout/Spinner.css new file mode 100644 index 00000000..51721285 --- /dev/null +++ b/src/components/layout/Spinner.css @@ -0,0 +1,20 @@ +.spinner { + font-size: 48px; + width: 1em; + height: 1em; + border: 0.12em solid var(--color,white); + border-bottom-color: transparent; + border-radius: 50%; + display: inline-block; + box-sizing: border-box; + animation: spinner-rotation 800ms linear infinite; +} + +@keyframes spinner-rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/src/components/layout/Spinner.tsx b/src/components/layout/Spinner.tsx new file mode 100644 index 00000000..77e06cfd --- /dev/null +++ b/src/components/layout/Spinner.tsx @@ -0,0 +1,9 @@ +import "./Spinner.css"; + +interface SpinnerProps { + className?: string; +} + +export function Spinner(props: SpinnerProps) { + return
; +} diff --git a/src/components/layout/ThinContainer.tsx b/src/components/layout/ThinContainer.tsx index c1866956..e1672f63 100644 --- a/src/components/layout/ThinContainer.tsx +++ b/src/components/layout/ThinContainer.tsx @@ -8,7 +8,9 @@ interface ThinContainerProps { export function ThinContainer(props: ThinContainerProps) { return (
{props.children}
diff --git a/src/components/layout/WideContainer.tsx b/src/components/layout/WideContainer.tsx new file mode 100644 index 00000000..f7d745fe --- /dev/null +++ b/src/components/layout/WideContainer.tsx @@ -0,0 +1,18 @@ +import { ReactNode } from "react"; + +interface WideContainerProps { + classNames?: string; + children?: ReactNode; +} + +export function WideContainer(props: WideContainerProps) { + return ( +
+ {props.children} +
+ ); +} diff --git a/src/components/media/EpisodeButton.tsx b/src/components/media/EpisodeButton.tsx index c4e851d0..f3e4375c 100644 --- a/src/components/media/EpisodeButton.tsx +++ b/src/components/media/EpisodeButton.tsx @@ -9,12 +9,12 @@ export function Episode(props: EpisodeProps) { return (
void; } function MediaCardContent({ media, linkable, - watchedPercentage, series, + percentage, + closable, + onClose, }: MediaCardProps) { - const provider = getProviderFromId(media.providerId); + const { t } = useTranslation(); + const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`; - if (!provider) { - return null; - } + const canLink = linkable && !closable; return ( -
- {/* progress background */} - {watchedPercentage > 0 ? ( -
+
+
+ {series ? ( +
+

+ {t("seasons.seasonAndEpisode", { + season: series.season, + episode: series.episode, + })} +

+
+ ) : null} + + {percentage !== undefined ? ( + <> +
+
+
+
+
+
+
+ + ) : null} +
-
+ closable && onClose?.()} + icon={Icons.X} + />
- ) : null} - -
- {/* card content */} -
-

- {media.title} - {series && media.seasonId && media.episodeId ? ( - - S{media.seasonId} E{media.episodeId} - - ) : null} -

- -
- - {/* hoverable chevron */} -
- -
-
-
+

+ {media.title} +

+ +
+
); } export function MediaCard(props: MediaCardProps) { - let link = "movie"; - if (props.media.mediaType === MWMediaType.SERIES) link = "series"; - const content = ; + const canLink = props.linkable && !props.closable; + + let link = canLink + ? `/media/${encodeURIComponent(JWMediaToId(props.media))}` + : "#"; + if (canLink && props.series) + link += `/${encodeURIComponent(props.series.seasonId)}/${encodeURIComponent( + props.series.episodeId + )}`; + if (!props.linkable) return {content}; - return ( - - {content} - - ); + return {content}; } diff --git a/src/components/media/MediaGrid.tsx b/src/components/media/MediaGrid.tsx new file mode 100644 index 00000000..a9f75b22 --- /dev/null +++ b/src/components/media/MediaGrid.tsx @@ -0,0 +1,15 @@ +import { forwardRef } from "react"; + +interface MediaGridProps { + children?: React.ReactNode; +} + +export const MediaGrid = forwardRef( + (props, ref) => { + return ( +
+ {props.children} +
+ ); + } +); diff --git a/src/components/media/VideoPlayer.tsx b/src/components/media/VideoPlayer.tsx deleted file mode 100644 index 0009922e..00000000 --- a/src/components/media/VideoPlayer.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { ReactElement, useEffect, useRef, useState } from "react"; -import Hls from "hls.js"; -import { IconPatch } from "@/components/buttons/IconPatch"; -import { Icons } from "@/components/Icon"; -import { Loading } from "@/components/layout/Loading"; -import { MWMediaCaption, MWMediaStream } from "@/providers"; - -export interface VideoPlayerProps { - source: MWMediaStream; - captions: MWMediaCaption[]; - startAt?: number; - onProgress?: (event: ProgressEvent) => void; -} - -export function SkeletonVideoPlayer(props: { error?: boolean }) { - return ( -
- {props.error ? ( -
- -

Couldn't get your stream

-
- ) : ( -
- -

Getting your stream...

-
- )} -
- ); -} - -export function VideoPlayer(props: VideoPlayerProps) { - const videoRef = useRef(null); - const [hasErrored, setErrored] = useState(false); - const [isLoading, setLoading] = useState(true); - const showVideo = !isLoading && !hasErrored; - const mustUseHls = props.source.type === "m3u8"; - - // reset if stream url changes - useEffect(() => { - setLoading(true); - setErrored(false); - - // hls support - if (mustUseHls) { - if (!videoRef.current) return; - - if (!Hls.isSupported()) { - setLoading(false); - setErrored(true); - return; - } - - const hls = new Hls(); - - if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) { - videoRef.current.src = props.source.url; - return; - } - - hls.attachMedia(videoRef.current); - hls.loadSource(props.source.url); - - hls.on(Hls.Events.ERROR, (event, data) => { - setErrored(true); - console.error(data); - }); - } - }, [props.source.url, videoRef, mustUseHls]); - - let skeletonUi: null | ReactElement = null; - if (hasErrored) { - skeletonUi = ; - } else if (isLoading) { - skeletonUi = ; - } - - return ( - <> - {skeletonUi} - - - ); -} diff --git a/src/components/media/WatchedEpisodeButton.tsx b/src/components/media/WatchedEpisodeButton.tsx deleted file mode 100644 index aa699859..00000000 --- a/src/components/media/WatchedEpisodeButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { getEpisodeFromMedia, MWMedia } from "@/providers"; -import { useWatchedContext, getWatchedFromPortable } from "@/state/watched"; -import { Episode } from "./EpisodeButton"; - -export interface WatchedEpisodeProps { - media: MWMedia; - onClick?: () => void; - active?: boolean; -} - -export function WatchedEpisode(props: WatchedEpisodeProps) { - const { watched } = useWatchedContext(); - const foundWatched = getWatchedFromPortable(watched.items, props.media); - const episode = getEpisodeFromMedia(props.media); - const watchedPercentage = (foundWatched && foundWatched.percentage) || 0; - - return ( - - ); -} diff --git a/src/components/media/WatchedMediaCard.tsx b/src/components/media/WatchedMediaCard.tsx index f8338d57..935ceb84 100644 --- a/src/components/media/WatchedMediaCard.tsx +++ b/src/components/media/WatchedMediaCard.tsx @@ -1,23 +1,44 @@ -import { MWMediaMeta } from "@/providers"; -import { useWatchedContext, getWatchedFromPortable } from "@/state/watched"; +import { MWMediaMeta } from "@/backend/metadata/types"; +import { useWatchedContext } from "@/state/watched"; +import { useMemo } from "react"; import { MediaCard } from "./MediaCard"; export interface WatchedMediaCardProps { media: MWMediaMeta; - series?: boolean; + closable?: boolean; + onClose?: () => void; +} + +function formatSeries( + obj: + | { episodeId: string; seasonId: string; episode: number; season: number } + | undefined +) { + if (!obj) return undefined; + return { + season: obj.season, + episode: obj.episode, + episodeId: obj.episodeId, + seasonId: obj.seasonId, + }; } export function WatchedMediaCard(props: WatchedMediaCardProps) { const { watched } = useWatchedContext(); - const foundWatched = getWatchedFromPortable(watched.items, props.media); - const watchedPercentage = (foundWatched && foundWatched.percentage) || 0; + const watchedMedia = useMemo(() => { + return watched.items + .sort((a, b) => b.watchedAt - a.watchedAt) + .find((v) => v.item.meta.id === props.media.id); + }, [watched, props.media]); return ( ); } diff --git a/src/components/text/DotList.tsx b/src/components/text/DotList.tsx index dc7b4dcd..cda5be09 100644 --- a/src/components/text/DotList.tsx +++ b/src/components/text/DotList.tsx @@ -5,7 +5,7 @@ export interface DotListProps { export function DotList(props: DotListProps) { return ( -

+

{props.content.map((item, index) => ( {index !== 0 ? ( diff --git a/src/components/text/Link.tsx b/src/components/text/Link.tsx index 7505f41c..1451114e 100644 --- a/src/components/text/Link.tsx +++ b/src/components/text/Link.tsx @@ -16,22 +16,27 @@ interface ILinkPropsInternal extends ILinkPropsBase { to: string; } -type LinkProps = - | ILinkPropsExternal - | ILinkPropsInternal - | ILinkPropsBase; +type LinkProps = ILinkPropsExternal | ILinkPropsInternal | ILinkPropsBase; export function Link(props: LinkProps) { const isExternal = !!(props as ILinkPropsExternal).url; const isInternal = !!(props as ILinkPropsInternal).to; const content = ( - + {props.children} ); if (isExternal) - return {content}; + return ( + + {content} + + ); if (isInternal) return ( {content} diff --git a/src/components/text/Tagline.tsx b/src/components/text/Tagline.tsx deleted file mode 100644 index 88633f5e..00000000 --- a/src/components/text/Tagline.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export interface TaglineProps { - children?: React.ReactNode; -} - -export function Tagline(props: TaglineProps) { - return

{props.children}

; -} diff --git a/src/components/text/Title.tsx b/src/components/text/Title.tsx index 436a2663..f30d6f8a 100644 --- a/src/components/text/Title.tsx +++ b/src/components/text/Title.tsx @@ -1,7 +1,16 @@ export interface TitleProps { children?: React.ReactNode; + className?: string; } export function Title(props: TitleProps) { - return

{props.children}

; + return ( +

+ {props.children} +

+ ); } diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index f9ac5da1..00000000 --- a/src/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb"; -export const GITHUB_LINK = "https://github.com/JamesHawkinss/movie-web"; -export const APP_VERSION = "2.1.0"; diff --git a/src/hooks/useChromecastAvailable.ts b/src/hooks/useChromecastAvailable.ts new file mode 100644 index 00000000..6190a288 --- /dev/null +++ b/src/hooks/useChromecastAvailable.ts @@ -0,0 +1,110 @@ +/// + +import { isChromecastAvailable } from "@/setup/chromecast"; +import { useEffect, useRef, useState } from "react"; + +export function useChromecastAvailable() { + const [available, setAvailable] = useState(null); + + useEffect(() => { + isChromecastAvailable((bool) => setAvailable(bool)); + }, []); + + return available; +} + +export function useChromecast() { + const available = useChromecastAvailable(); + const instance = useRef(null); + const remotePlayerController = + useRef(null); + + function startCast() { + const movieMeta = new chrome.cast.media.MovieMediaMetadata(); + movieMeta.title = "Big Buck Bunny"; + + const mediaInfo = new chrome.cast.media.MediaInfo("hello", "video/mp4"); + (mediaInfo as any).contentUrl = + "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"; + mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; + mediaInfo.metadata = movieMeta; + + const request = new chrome.cast.media.LoadRequest(mediaInfo); + request.autoplay = true; + + const session = instance.current?.getCurrentSession(); + console.log("testing", session); + if (!session) return; + + session + .loadMedia(request) + .then(() => { + console.log("Media is loaded"); + }) + .catch((e: any) => { + console.error(e); + }); + } + + function stopCast() { + const session = instance.current?.getCurrentSession(); + if (!session) return; + + const controller = remotePlayerController.current; + if (!controller) return; + controller.stop(); + } + + useEffect(() => { + if (!available) return; + + // setup instance if not already + if (!instance.current) { + const ins = cast.framework.CastContext.getInstance(); + ins.setOptions({ + receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, + autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED, + }); + instance.current = ins; + } + + // setup player if not already + if (!remotePlayerController.current) { + const player = new cast.framework.RemotePlayer(); + const controller = new cast.framework.RemotePlayerController(player); + remotePlayerController.current = controller; + } + + // setup event listener + function listenToEvents(e: cast.framework.RemotePlayerChangedEvent) { + console.log("chromecast event", e); + } + function connectionChanged(e: cast.framework.RemotePlayerChangedEvent) { + console.log("chromecast event connection changed", e); + } + remotePlayerController.current.addEventListener( + cast.framework.RemotePlayerEventType.PLAYER_STATE_CHANGED, + listenToEvents + ); + remotePlayerController.current.addEventListener( + cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED, + connectionChanged + ); + + return () => { + remotePlayerController.current?.removeEventListener( + cast.framework.RemotePlayerEventType.PLAYER_STATE_CHANGED, + listenToEvents + ); + remotePlayerController.current?.removeEventListener( + cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED, + connectionChanged + ); + }; + }, [available]); + + return { + startCast, + stopCast, + }; +} diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts index 509337b6..fdc9b6db 100644 --- a/src/hooks/useDebounce.ts +++ b/src/hooks/useDebounce.ts @@ -4,17 +4,14 @@ export function useDebounce(value: T, delay: number): T { // State and setters for debounced value const [debouncedValue, setDebouncedValue] = useState(value); - useEffect( - () => { - const handler = setTimeout(() => { - setDebouncedValue(value); - }, delay); - return () => { - clearTimeout(handler); - }; - }, - [value, delay] - ); + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + return () => { + clearTimeout(handler); + }; + }, [value, delay]); return debouncedValue; } diff --git a/src/hooks/useFade.ts b/src/hooks/useFade.ts index e03413fd..58438acf 100644 --- a/src/hooks/useFade.ts +++ b/src/hooks/useFade.ts @@ -1,7 +1,9 @@ import React, { useEffect, useState } from "react"; -import './useFade.css' +import "./useFade.css"; -export const useFade = (initial = false): [boolean, React.Dispatch>, any] => { +export const useFade = ( + initial = false +): [boolean, React.Dispatch>, any] => { const [show, setShow] = useState(initial); const [isVisible, setVisible] = useState(show); @@ -20,7 +22,7 @@ export const useFade = (initial = false): [boolean, React.Dispatch { + if (reactHistory.action !== "POP") reactHistory.goBack(); + else reactHistory.push("/"); + }, [reactHistory]); + return goBack; +} diff --git a/src/hooks/useIsMobile.ts b/src/hooks/useIsMobile.ts new file mode 100644 index 00000000..a336afdb --- /dev/null +++ b/src/hooks/useIsMobile.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef, useState } from "react"; + +export function useIsMobile() { + const [isMobile, setIsMobile] = useState(false); + const isMobileCurrent = useRef(false); + + useEffect(() => { + function onResize() { + const value = window.innerWidth < 1024; + const isChanged = isMobileCurrent.current !== value; + if (!isChanged) return; + + isMobileCurrent.current = value; + setIsMobile(value); + } + + onResize(); + window.addEventListener("resize", onResize); + + return () => { + window.removeEventListener("resize", onResize); + }; + }, []); + + return { + isMobile, + }; +} diff --git a/src/hooks/useLoading.ts b/src/hooks/useLoading.ts index 05411004..987456db 100644 --- a/src/hooks/useLoading.ts +++ b/src/hooks/useLoading.ts @@ -2,7 +2,12 @@ import React, { useMemo, useRef, useState } from "react"; export function useLoading Promise>( action: T -) { +): [ + (...args: Parameters) => ReturnType | Promise, + boolean, + Error | undefined, + boolean +] { const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(false); const [error, setError] = useState(undefined); @@ -20,11 +25,11 @@ export function useLoading Promise>( const doAction = useMemo( () => - async (...args: Parameters) => { + async (...args: any) => { setLoading(true); setSuccess(false); setError(undefined); - return new Promise((resolve) => { + return new Promise((resolve) => { actionMemo(...args) .then((v) => { if (!isMounted.current) return resolve(undefined); @@ -35,6 +40,7 @@ export function useLoading Promise>( .catch((err) => { if (isMounted) { setError(err); + console.error("USELOADING ERROR", err); setSuccess(false); } resolve(undefined); diff --git a/src/hooks/usePortableMedia.ts b/src/hooks/usePortableMedia.ts deleted file mode 100644 index 81744fb9..00000000 --- a/src/hooks/usePortableMedia.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; -import { MWPortableMedia } from "@/providers"; - -export function deserializePortableMedia(media: string): MWPortableMedia { - return JSON.parse(atob(decodeURIComponent(media))); -} - -export function serializePortableMedia(media: MWPortableMedia): string { - const data = encodeURIComponent(btoa(JSON.stringify(media))); - return data; -} - -export function usePortableMedia(): MWPortableMedia | undefined { - const { media } = useParams<{ media: string }>(); - const [mediaObject, setMediaObject] = useState( - undefined - ); - - useEffect(() => { - try { - setMediaObject(deserializePortableMedia(media)); - } catch (err) { - console.error("Failed to deserialize portable media", err); - setMediaObject(undefined); - } - }, [media, setMediaObject]); - - return mediaObject; -} diff --git a/src/hooks/useProgressBar.ts b/src/hooks/useProgressBar.ts new file mode 100644 index 00000000..252ed3b7 --- /dev/null +++ b/src/hooks/useProgressBar.ts @@ -0,0 +1,91 @@ +import React, { RefObject, useCallback, useEffect, useState } from "react"; + +type ActivityEvent = + | React.MouseEvent + | React.TouchEvent + | MouseEvent + | TouchEvent; + +export function makePercentageString(num: number) { + return `${num.toFixed(2)}%`; +} + +export function makePercentage(num: number) { + return Number(Math.max(0, Math.min(num, 100)).toFixed(2)); +} + +function isClickEvent( + evt: ActivityEvent +): evt is React.MouseEvent | MouseEvent { + return ( + evt.type === "mousedown" || + evt.type === "mouseup" || + evt.type === "mousemove" + ); +} + +const getEventX = (evt: ActivityEvent) => { + return isClickEvent(evt) ? evt.pageX : evt.changedTouches[0].pageX; +}; + +export function useProgressBar( + barRef: RefObject, + commit: (percentage: number) => void, + commitImmediately = false +) { + const [mouseDown, setMouseDown] = useState(false); + const [progress, setProgress] = useState(0); + + useEffect(() => { + function mouseMove(ev: ActivityEvent) { + if (!mouseDown || !barRef.current) return; + const rect = barRef.current.getBoundingClientRect(); + const pos = (getEventX(ev) - rect.left) / barRef.current.offsetWidth; + setProgress(pos * 100); + if (commitImmediately) commit(pos); + } + + function mouseUp(ev: ActivityEvent) { + if (!mouseDown) return; + setMouseDown(false); + document.body.removeAttribute("data-no-select"); + + if (!barRef.current) return; + const rect = barRef.current.getBoundingClientRect(); + const pos = (getEventX(ev) - rect.left) / barRef.current.offsetWidth; + commit(pos); + } + + document.addEventListener("mousemove", mouseMove); + document.addEventListener("touchmove", mouseMove); + document.addEventListener("mouseup", mouseUp); + document.addEventListener("touchend", mouseUp); + + return () => { + document.removeEventListener("mousemove", mouseMove); + document.removeEventListener("touchmove", mouseMove); + document.removeEventListener("mouseup", mouseUp); + document.removeEventListener("touchend", mouseUp); + }; + }, [mouseDown, barRef, commit, commitImmediately]); + + const dragMouseDown = useCallback( + (ev: ActivityEvent) => { + setMouseDown(true); + document.body.setAttribute("data-no-select", "true"); + + if (!barRef.current) return; + const rect = barRef.current.getBoundingClientRect(); + const pos = + ((getEventX(ev) - rect.left) / barRef.current.offsetWidth) * 100; + setProgress(pos); + }, + [setProgress, barRef] + ); + + return { + dragging: mouseDown, + dragPercentage: progress, + dragMouseDown, + }; +} diff --git a/src/hooks/useScrape.ts b/src/hooks/useScrape.ts new file mode 100644 index 00000000..05f3c387 --- /dev/null +++ b/src/hooks/useScrape.ts @@ -0,0 +1,74 @@ +import { findBestStream } from "@/backend/helpers/scrape"; +import { MWStream } from "@/backend/helpers/streams"; +import { DetailedMeta } from "@/backend/metadata/getmeta"; +import { MWMediaType } from "@/backend/metadata/types"; +import { useEffect, useState } from "react"; + +export interface ScrapeEventLog { + type: "provider" | "embed"; + errored: boolean; + percentage: number; + eventId: string; + id: string; +} + +export type SelectedMediaData = + | { + type: MWMediaType.SERIES; + episode: string; + season: string; + } + | { + type: MWMediaType.MOVIE | MWMediaType.ANIME; + episode: undefined; + season: undefined; + }; + +export function useScrape(meta: DetailedMeta, selected: SelectedMediaData) { + const [eventLog, setEventLog] = useState([]); + const [stream, setStream] = useState(null); + const [pending, setPending] = useState(true); + + useEffect(() => { + setPending(true); + setStream(null); + setEventLog([]); + (async () => { + const scrapedStream = await findBestStream({ + media: meta, + ...selected, + onNext(ctx) { + setEventLog((arr) => [ + ...arr, + { + errored: false, + id: ctx.id, + eventId: ctx.eventId, + type: ctx.type, + percentage: 0, + }, + ]); + }, + onProgress(ctx) { + setEventLog((arr) => { + const item = arr.reverse().find((v) => v.id === ctx.id); + if (item) { + item.errored = ctx.errored; + item.percentage = ctx.percentage; + } + return [...arr]; + }); + }, + }); + + setPending(false); + setStream(scrapedStream); + })(); + }, [meta, selected]); + + return { + stream, + pending, + eventLog, + }; +} diff --git a/src/hooks/useSearchQuery.ts b/src/hooks/useSearchQuery.ts index 7f83f6aa..372c70fc 100644 --- a/src/hooks/useSearchQuery.ts +++ b/src/hooks/useSearchQuery.ts @@ -1,6 +1,14 @@ -import React, { useRef, useState } from "react"; +import { MWMediaType, MWQuery } from "@/backend/metadata/types"; +import { useState } from "react"; import { generatePath, useHistory, useRouteMatch } from "react-router-dom"; -import { MWMediaType, MWQuery } from "@/providers"; + +function getInitialValue(params: { type: string; query: string }) { + const type = + Object.values(MWMediaType).find((v) => params.type === v) || + MWMediaType.MOVIE; + const searchQuery = params.query || ""; + return { type, searchQuery }; +} export function useSearchQuery(): [ MWQuery, @@ -8,12 +16,8 @@ export function useSearchQuery(): [ () => void ] { const history = useHistory(); - const isFirstRender = useRef(true); const { path, params } = useRouteMatch<{ type: string; query: string }>(); - const [search, setSearch] = useState({ - searchQuery: "", - type: MWMediaType.MOVIE, - }); + const [search, setSearch] = useState(getInitialValue(params)); const updateParams = (inp: Partial, force: boolean) => { const copySearch: MWQuery = { ...search }; @@ -38,18 +42,5 @@ export function useSearchQuery(): [ ); }; - // only run on first load of the page - React.useEffect(() => { - if (isFirstRender.current === false) { - return; - } - isFirstRender.current = false; - const type = - Object.values(MWMediaType).find((v) => params.type === v) || - MWMediaType.MOVIE; - const searchQuery = params.query || ""; - setSearch({ type, searchQuery }); - }, [setSearch, params, isFirstRender]); - return [search, updateParams, onUnFocus]; } diff --git a/src/hooks/useVolumeToggle.ts b/src/hooks/useVolumeToggle.ts new file mode 100644 index 00000000..636b787b --- /dev/null +++ b/src/hooks/useVolumeToggle.ts @@ -0,0 +1,24 @@ +import { useControls } from "@/video/state/logic/controls"; +import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; +import { useState } from "react"; + +export function useVolumeControl(descriptor: string) { + const [storedVolume, setStoredVolume] = useState(1); + const controls = useControls(descriptor); + const mediaPlaying = useMediaPlaying(descriptor); + + const toggleVolume = () => { + if (mediaPlaying.volume > 0) { + setStoredVolume(mediaPlaying.volume); + controls.setVolume(0); + } else { + controls.setVolume(storedVolume > 0 ? storedVolume : 1); + } + }; + + return { + storedVolume, + setStoredVolume, + toggleVolume, + }; +} diff --git a/src/i18n.ts b/src/i18n.ts deleted file mode 100644 index 8ab960b1..00000000 --- a/src/i18n.ts +++ /dev/null @@ -1,28 +0,0 @@ -import i18n from 'i18next'; -import { initReactI18next } from 'react-i18next'; - -import Backend from 'i18next-http-backend'; -import LanguageDetector from 'i18next-browser-languagedetector'; - -i18n - // load translation using http -> see /public/locales (i.e. https://github.com/i18next/react-i18next/tree/master/example/react/public/locales) - // learn more: https://github.com/i18next/i18next-http-backend - // want your translations to be loaded from a professional CDN? => https://github.com/locize/react-tutorial#step-2---use-the-locize-cdn - .use(Backend) - // detect user language - // learn more: https://github.com/i18next/i18next-browser-languageDetector - .use(LanguageDetector) - // pass the i18n instance to react-i18next. - .use(initReactI18next) - // init i18next - // for all options read: https://www.i18next.com/overview/configuration-options - .init({ - fallbackLng: 'en-GB', - - interpolation: { - escapeValue: false, // not needed for react as it escapes by default - } - }); - - -export default i18n; \ No newline at end of file diff --git a/src/index.css b/src/index.css deleted file mode 100644 index eadd4e89..00000000 --- a/src/index.css +++ /dev/null @@ -1,16 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -html, -body { - @apply bg-denim-100 text-denim-700 font-open-sans min-h-screen; -} - -#root { - display: flex; - justify-content: flex-start; - align-items: flex-start; - min-height: 100vh; - width: 100%; -} diff --git a/src/index.tsx b/src/index.tsx index d0a08f46..8de72d2c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,15 @@ -import React, { Suspense } from "react"; +import React, { ReactNode, Suspense } from "react"; import ReactDOM from "react-dom"; -import { HashRouter } from "react-router-dom"; -import "./index.css"; +import { BrowserRouter, HashRouter } from "react-router-dom"; import { ErrorBoundary } from "@/components/layout/ErrorBoundary"; -import App from "./App"; -import "./i18n"; -import { conf } from "./config"; +import { conf } from "@/setup/config"; + +import App from "@/setup/App"; +import "@/setup/i18n"; +import "@/setup/index.css"; +import "@/backend"; +import { initializeChromecast } from "./setup/chromecast"; +import { initializeStores } from "./utils/storage"; // initialize const key = @@ -13,15 +17,30 @@ const key = if (key) { (window as any).initMW(conf().BASE_PROXY_URL, key); } +initializeChromecast(); + +const LazyLoadedApp = React.lazy(async () => { + await initializeStores(); + return { + default: App, + }; +}); + +function TheRouter(props: { children: ReactNode }) { + const normalRouter = conf().NORMAL_ROUTER; + + if (normalRouter) return {props.children}; + return {props.children}; +} ReactDOM.render( - + - + - + , document.getElementById("root") diff --git a/src/providers/README.md b/src/providers/README.md deleted file mode 100644 index a32dcc4f..00000000 --- a/src/providers/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# the providers - -to make this as clear as possible, here is some extra information on how the interal system works regarding providers. - -| Term | explanation | -| ------------- | ------------------------------------------------------------------------------------- | -| Media | Object containing information about a piece of media. like title and its id's | -| PortableMedia | Object with just the identifiers of a piece of media. used for transport and saving | -| MediaStream | Object with a stream url in it. use it to view a piece of media. | -| Provider | group of methods to generate media and mediastreams from a source. aliased as scraper | - -All types are prefixed with MW (MovieWeb) to prevent clashing names. - -## Some rules - -1. **Never** remove a provider completely if it's been in use before. just disable it. -2. **Never** change the ID of a provider if it's been in use before. -3. **Never** change system of the media ID of a provider without making it backwards compatible - -All these rules are because `PortableMedia` objects need to stay functional. because: - -- It's used for routing, links would stop working -- It's used for storage, continue watching and bookmarks would stop working - -# The list of providers and their quirks - -Some providers have quirks, stuff they do differently than other providers - -## TheFlix - -- for series, the latest episode released will be one playing at first when you select it from search results diff --git a/src/providers/index.ts b/src/providers/index.ts deleted file mode 100644 index 5ea5cfbb..00000000 --- a/src/providers/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { getProviderFromId } from "./methods/helpers"; -import { MWMedia, MWPortableMedia, MWMediaStream } from "./types"; - -export * from "./types"; -export * from "./methods/helpers"; -export * from "./methods/providers"; -export * from "./methods/search"; - -/* - ** Turn media object into a portable media object - */ -export function convertMediaToPortable(media: MWMedia): MWPortableMedia { - return { - mediaId: media.mediaId, - providerId: media.providerId, - mediaType: media.mediaType, - episodeId: media.episodeId, - seasonId: media.seasonId, - }; -} - -/* - ** Turn portable media into media object - */ -export async function convertPortableToMedia( - portable: MWPortableMedia -): Promise { - const provider = getProviderFromId(portable.providerId); - return provider?.getMediaFromPortable(portable); -} - -/* - ** find provider from portable and get stream from that provider - */ -export async function getStream( - media: MWPortableMedia -): Promise { - const provider = getProviderFromId(media.providerId); - if (!provider) return undefined; - - return provider.getStream(media); -} diff --git a/src/providers/list/flixhq/index.ts b/src/providers/list/flixhq/index.ts deleted file mode 100644 index 8fe6564d..00000000 --- a/src/providers/list/flixhq/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - MWMediaProvider, - MWMediaType, - MWPortableMedia, - MWMediaStream, - MWQuery, - MWProviderMediaResult, -} from "@/providers/types"; - -import { conf } from "@/config"; - -export const flixhqProvider: MWMediaProvider = { - id: "flixhq", - enabled: true, - type: [MWMediaType.MOVIE], - displayName: "flixhq", - - async getMediaFromPortable( - media: MWPortableMedia - ): Promise { - const searchRes = await fetch( - `${ - conf().CORS_PROXY_URL - }https://api.consumet.org/movies/flixhq/info?id=${encodeURIComponent( - media.mediaId - )}` - ).then((d) => d.json()); - - return { - ...media, - title: searchRes.title, - year: searchRes.releaseDate, - } as MWProviderMediaResult; - }, - - async searchForMedia(query: MWQuery): Promise { - const searchRes = await fetch( - `${ - conf().CORS_PROXY_URL - }https://api.consumet.org/movies/flixhq/${encodeURIComponent( - query.searchQuery - )}` - ).then((d) => d.json()); - - const results: MWProviderMediaResult[] = (searchRes || []).results.map( - (item: any) => ({ - title: item.title, - year: item.releaseDate, - mediaId: item.id, - type: MWMediaType.MOVIE, - }) - ); - - return results; - }, - - async getStream(media: MWPortableMedia): Promise { - const searchRes = await fetch( - `${ - conf().CORS_PROXY_URL - }https://api.consumet.org/movies/flixhq/info?id=${encodeURIComponent( - media.mediaId - )}` - ).then((d) => d.json()); - - const params = new URLSearchParams({ - episodeId: searchRes.episodes[0].id, - mediaId: media.mediaId, - }); - - const watchRes = await fetch( - `${ - conf().CORS_PROXY_URL - }https://api.consumet.org/movies/flixhq/watch?${encodeURIComponent( - params.toString() - )}` - ).then((d) => d.json()); - - const source = watchRes.sources.reduce((p: any, c: any) => - c.quality > p.quality ? c : p - ); - - return { - url: source.url, - type: source.isM3U8 ? "m3u8" : "mp4", - captions: [], - } as MWMediaStream; - }, -}; diff --git a/src/providers/list/gomostream/index.ts b/src/providers/list/gomostream/index.ts deleted file mode 100644 index 092645d4..00000000 --- a/src/providers/list/gomostream/index.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { unpack } from "unpacker"; -import json5 from "json5"; -import { - MWMediaProvider, - MWMediaType, - MWPortableMedia, - MWMediaStream, - MWQuery, - MWProviderMediaResult, -} from "@/providers/types"; - -import { conf } from "@/config"; - -export const gomostreamScraper: MWMediaProvider = { - id: "gomostream", - enabled: true, - type: [MWMediaType.MOVIE], - displayName: "gomostream", - - async getMediaFromPortable( - media: MWPortableMedia - ): Promise { - const params = new URLSearchParams({ - apikey: conf().OMDB_API_KEY, - i: media.mediaId, - type: media.mediaType, - }); - - const res = await fetch( - `${conf().CORS_PROXY_URL}http://www.omdbapi.com/?${encodeURIComponent( - params.toString() - )}` - ).then((d) => d.json()); - - return { - ...media, - title: res.Title, - year: res.Year, - } as MWProviderMediaResult; - }, - - async searchForMedia(query: MWQuery): Promise { - const term = query.searchQuery.toLowerCase(); - - const params = new URLSearchParams({ - apikey: conf().OMDB_API_KEY, - s: term, - type: query.type, - }); - const searchRes = await fetch( - `${conf().CORS_PROXY_URL}http://www.omdbapi.com/?${encodeURIComponent( - params.toString() - )}` - ).then((d) => d.json()); - - const results: MWProviderMediaResult[] = (searchRes.Search || []).map( - (d: any) => - ({ - title: d.Title, - year: d.Year, - mediaId: d.imdbID, - } as MWProviderMediaResult) - ); - - return results; - }, - - async getStream(media: MWPortableMedia): Promise { - const type = - media.mediaType === MWMediaType.SERIES ? "show" : media.mediaType; - const res1 = await fetch( - `${conf().CORS_PROXY_URL}https://gomo.to/${type}/${media.mediaId}` - ).then((d) => d.text()); - if (res1 === "Movie not available." || res1 === "Episode not available.") - throw new Error(res1); - - const tc = res1.match(/var tc = '(.+)';/)?.[1] || ""; - const _token = res1.match(/"_token": "(.+)",/)?.[1] || ""; - - const fd = new FormData(); - fd.append("tokenCode", tc); - fd.append("_token", _token); - - const src = await fetch( - `${conf().CORS_PROXY_URL}https://gomo.to/decoding_v3.php`, - { - method: "POST", - body: fd, - headers: { - "x-token": `${tc.slice(5, 13).split("").reverse().join("")}13574199`, - }, - } - ).then((d) => d.json()); - const embeds = src.filter((url: string) => url.includes("gomo.to")); - - // maybe try all embeds in the future - const embedUrl = embeds[1]; - const res2 = await fetch(`${conf().CORS_PROXY_URL}${embedUrl}`).then((d) => - d.text() - ); - - const res2DOM = new DOMParser().parseFromString(res2, "text/html"); - if (res2DOM.body.innerText === "File was deleted") - throw new Error("File was deleted"); - - const script = Array.from(res2DOM.querySelectorAll("script")).find( - (s: HTMLScriptElement) => - s.innerHTML.includes("eval(function(p,a,c,k,e,d") - )?.innerHTML; - if (!script) throw new Error("Could not get packed data"); - - const unpacked = unpack(script); - const rawSources = /sources:(\[.*?\])/.exec(unpacked); - if (!rawSources) throw new Error("Could not get rawSources"); - - const sources = json5.parse(rawSources[1]); - const streamUrl = sources[0].file; - - const streamType = streamUrl.split(".").at(-1); - if (streamType !== "mp4" && streamType !== "m3u8") - throw new Error("Unsupported stream type"); - - return { url: streamUrl, type: streamType, captions: [] }; - }, -}; diff --git a/src/providers/list/superstream/index.ts b/src/providers/list/superstream/index.ts deleted file mode 100644 index 3dc26e7b..00000000 --- a/src/providers/list/superstream/index.ts +++ /dev/null @@ -1,307 +0,0 @@ -// this is derived from https://github.com/recloudstream/cloudstream-extensions -// for more info please check the LICENSE file in the same directory - -import { customAlphabet } from "nanoid"; -import toWebVTT from "srt-webvtt"; -import CryptoJS from "crypto-js"; -import { conf } from "@/config"; -import { - MWMediaProvider, - MWMediaType, - MWPortableMedia, - MWMediaStream, - MWQuery, - MWMediaSeasons, - MWProviderMediaResult, -} from "@/providers/types"; - -const nanoid = customAlphabet("0123456789abcdef", 32); - -// CONSTANTS, read below (taken from og) -// We do not want content scanners to notice this scraping going on so we've hidden all constants -// The source has its origins in China so I added some extra security with banned words -// Mayhaps a tiny bit unethical, but this source is just too good :) -// If you are copying this code please use precautions so they do not change their api. -const iv = atob("d0VpcGhUbiE="); -const key = atob("MTIzZDZjZWRmNjI2ZHk1NDIzM2FhMXc2"); -const apiUrls = [ - atob("aHR0cHM6Ly9zaG93Ym94LnNoZWd1Lm5ldC9hcGkvYXBpX2NsaWVudC9pbmRleC8="), - atob("aHR0cHM6Ly9tYnBhcGkuc2hlZ3UubmV0L2FwaS9hcGlfY2xpZW50L2luZGV4Lw=="), -]; -const appKey = atob("bW92aWVib3g="); -const appId = atob("Y29tLnRkby5zaG93Ym94"); - -// cryptography stuff -const crypto = { - encrypt(str: string) { - return CryptoJS.TripleDES.encrypt(str, CryptoJS.enc.Utf8.parse(key), { - iv: CryptoJS.enc.Utf8.parse(iv), - }).toString(); - }, - getVerify(str: string, str2: string, str3: string) { - if (str) { - return CryptoJS.MD5( - CryptoJS.MD5(str2).toString() + str3 + str - ).toString(); - } - return null; - }, -}; - -// get expire time -const expiry = () => Math.floor(Date.now() / 1000 + 60 * 60 * 12); - -// sending requests -const get = (data: object, altApi = false) => { - const defaultData = { - childmode: "0", - app_version: "11.5", - appid: appId, - lang: "en", - expired_date: `${expiry()}`, - platform: "android", - channel: "Website", - }; - const encryptedData = crypto.encrypt( - JSON.stringify({ - ...defaultData, - ...data, - }) - ); - const appKeyHash = CryptoJS.MD5(appKey).toString(); - const verify = crypto.getVerify(encryptedData, appKey, key); - const body = JSON.stringify({ - app_key: appKeyHash, - verify, - encrypt_data: encryptedData, - }); - const b64Body = btoa(body); - - const formatted = new URLSearchParams(); - formatted.append("data", b64Body); - formatted.append("appid", "27"); - formatted.append("platform", "android"); - formatted.append("version", "129"); - formatted.append("medium", "Website"); - - const requestUrl = altApi ? apiUrls[1] : apiUrls[0]; - return fetch(`${conf().CORS_PROXY_URL}${requestUrl}`, { - method: "POST", - headers: { - Platform: "android", - "Content-Type": "application/x-www-form-urlencoded", - }, - body: `${formatted.toString()}&token${nanoid()}`, - }); -}; - -export const superStreamScraper: MWMediaProvider = { - id: "superstream", - enabled: true, - type: [MWMediaType.MOVIE, MWMediaType.SERIES], - displayName: "SuperStream", - - async getMediaFromPortable( - media: MWPortableMedia - ): Promise { - let apiQuery: any; - if (media.mediaType === MWMediaType.SERIES) { - apiQuery = { - module: "TV_detail_1", - display_all: "1", - tid: media.mediaId, - }; - } else { - apiQuery = { - module: "Movie_detail", - mid: media.mediaId, - }; - } - const detailRes = (await get(apiQuery, true).then((r) => r.json())).data; - - return { - ...media, - title: detailRes.title, - year: detailRes.year, - seasonCount: detailRes?.season?.length, - } as MWProviderMediaResult; - }, - - async searchForMedia(query: MWQuery): Promise { - const apiQuery = { - module: "Search3", - page: "1", - type: "all", - keyword: query.searchQuery, - pagelimit: "20", - }; - const searchRes = (await get(apiQuery, true).then((r) => r.json())).data; - - const movieResults: MWProviderMediaResult[] = (searchRes || []) - .filter((item: any) => item.box_type === 1) - .map((item: any) => ({ - title: item.title, - year: item.year, - mediaId: item.id, - })); - const seriesResults: MWProviderMediaResult[] = (searchRes || []) - .filter((item: any) => item.box_type === 2) - .map((item: any) => ({ - title: item.title, - year: item.year, - mediaId: item.id, - seasonId: "1", - episodeId: "1", - })); - - if (query.type === MWMediaType.MOVIE) { - return movieResults; - } - if (query.type === MWMediaType.SERIES) { - return seriesResults; - } - throw new Error("Invalid media type used."); - }, - - async getStream(media: MWPortableMedia): Promise { - if (media.mediaType === MWMediaType.MOVIE) { - const apiQuery = { - uid: "", - module: "Movie_downloadurl_v3", - mid: media.mediaId, - oss: "1", - group: "", - }; - const mediaRes = (await get(apiQuery).then((r) => r.json())).data; - const hdQuality = - mediaRes.list.find( - (quality: any) => quality.quality === "1080p" && quality.path - ) ?? - mediaRes.list.find( - (quality: any) => quality.quality === "720p" && quality.path - ) ?? - mediaRes.list.find( - (quality: any) => quality.quality === "480p" && quality.path - ) ?? - mediaRes.list.find( - (quality: any) => quality.quality === "360p" && quality.path - ); - - if (!hdQuality) throw new Error("No quality could be found."); - - const subtitleApiQuery = { - fid: hdQuality.fid, - uid: "", - module: "Movie_srt_list_v2", - mid: media.mediaId, - }; - const subtitleRes = (await get(subtitleApiQuery).then((r) => r.json())) - .data; - const mappedCaptions = await Promise.all( - subtitleRes.list.map(async (subtitle: any) => { - const captionBlob = await fetch( - `${conf().CORS_PROXY_URL}${subtitle.subtitles[0].file_path}` - ).then((captionRes) => captionRes.blob()); // cross-origin bypass - const captionUrl = await toWebVTT(captionBlob); // convert to vtt so it's playable - return { - id: subtitle.language, - url: captionUrl, - label: subtitle.language, - }; - }) - ); - - return { url: hdQuality.path, type: "mp4", captions: mappedCaptions }; - } - - const apiQuery = { - uid: "", - module: "TV_downloadurl_v3", - episode: media.episodeId, - tid: media.mediaId, - season: media.seasonId, - oss: "1", - group: "", - }; - const mediaRes = (await get(apiQuery).then((r) => r.json())).data; - const hdQuality = - mediaRes.list.find( - (quality: any) => quality.quality === "1080p" && quality.path - ) ?? - mediaRes.list.find( - (quality: any) => quality.quality === "720p" && quality.path - ) ?? - mediaRes.list.find( - (quality: any) => quality.quality === "480p" && quality.path - ) ?? - mediaRes.list.find( - (quality: any) => quality.quality === "360p" && quality.path - ); - - if (!hdQuality) throw new Error("No quality could be found."); - - const subtitleApiQuery = { - fid: hdQuality.fid, - uid: "", - module: "TV_srt_list_v2", - episode: media.episodeId, - tid: media.mediaId, - season: media.seasonId, - }; - const subtitleRes = (await get(subtitleApiQuery).then((r) => r.json())) - .data; - const mappedCaptions = await Promise.all( - subtitleRes.list.map(async (subtitle: any) => { - const captionBlob = await fetch( - `${conf().CORS_PROXY_URL}${subtitle.subtitles[0].file_path}` - ).then((captionRes) => captionRes.blob()); // cross-origin bypass - const captionUrl = await toWebVTT(captionBlob); // convert to vtt so it's playable - return { - id: subtitle.language, - url: captionUrl, - label: subtitle.language, - }; - }) - ); - - return { url: hdQuality.path, type: "mp4", captions: mappedCaptions }; - }, - async getSeasonDataFromMedia( - media: MWPortableMedia - ): Promise { - const apiQuery = { - module: "TV_detail_1", - display_all: "1", - tid: media.mediaId, - }; - const detailRes = (await get(apiQuery, true).then((r) => r.json())).data; - const firstSearchResult = ( - await fetch( - `https://api.themoviedb.org/3/search/tv?api_key=${ - conf().TMDB_API_KEY - }&language=en-US&page=1&query=${detailRes.title}&include_adult=false` - ).then((r) => r.json()) - ).results[0]; - const showDetails = await fetch( - `https://api.themoviedb.org/3/tv/${firstSearchResult.id}?api_key=${ - conf().TMDB_API_KEY - }` - ).then((r) => r.json()); - - return { - seasons: showDetails.seasons.map((season: any) => ({ - sort: season.season_number, - id: season.season_number.toString(), - type: season.season_number === 0 ? "special" : "season", - episodes: Array.from({ length: season.episode_count }).map( - (_, epNum) => ({ - title: `Episode ${epNum + 1}`, - sort: epNum + 1, - id: (epNum + 1).toString(), - episodeNumber: epNum + 1, - }) - ), - })), - }; - }, -}; diff --git a/src/providers/list/theflix/index.ts b/src/providers/list/theflix/index.ts deleted file mode 100644 index cdfe8e66..00000000 --- a/src/providers/list/theflix/index.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { - MWMediaProvider, - MWMediaType, - MWPortableMedia, - MWMediaStream, - MWQuery, - MWMediaSeasons, - MWProviderMediaResult, -} from "@/providers/types"; - -import { - searchTheFlix, - getDataFromSearch, - turnDataIntoMedia, -} from "@/providers/list/theflix/search"; - -import { getDataFromPortableSearch } from "@/providers/list/theflix/portableToMedia"; -import { conf } from "@/config"; - -export const theFlixScraper: MWMediaProvider = { - id: "theflix", - enabled: false, - type: [MWMediaType.MOVIE, MWMediaType.SERIES], - displayName: "theflix", - - async getMediaFromPortable( - media: MWPortableMedia - ): Promise { - const data: any = await getDataFromPortableSearch(media); - - return { - ...media, - year: new Date(data.releaseDate).getFullYear().toString(), - title: data.name, - }; - }, - - async searchForMedia(query: MWQuery): Promise { - const searchRes = await searchTheFlix(query); - const searchData = await getDataFromSearch(searchRes, 10); - - const results: MWProviderMediaResult[] = []; - for (const item of searchData) { - results.push(turnDataIntoMedia(item)); - } - - return results; - }, - - async getStream(media: MWPortableMedia): Promise { - let url = ""; - - if (media.mediaType === MWMediaType.MOVIE) { - url = `${conf().CORS_PROXY_URL}https://theflix.to/movie/${ - media.mediaId - }?movieInfo=${media.mediaId}`; - } else if (media.mediaType === MWMediaType.SERIES) { - url = `${conf().CORS_PROXY_URL}https://theflix.to/tv-show/${ - media.mediaId - }/season-${media.seasonId}/episode-${media.episodeId}`; - } - - const res = await fetch(url).then((d) => d.text()); - - const prop: HTMLElement | undefined = Array.from( - new DOMParser() - .parseFromString(res, "text/html") - .querySelectorAll("script") - ).find((e) => e.textContent?.includes("theflixvd.b-cdn")); - - if (!prop || !prop.textContent) { - throw new Error("Could not find stream"); - } - - const data = JSON.parse(prop.textContent); - - return { url: data.props.pageProps.videoUrl, type: "mp4", captions: [] }; - }, - - async getSeasonDataFromMedia( - media: MWPortableMedia - ): Promise { - const url = `${conf().CORS_PROXY_URL}https://theflix.to/tv-show/${ - media.mediaId - }/season-${media.seasonId}/episode-${media.episodeId}`; - const res = await fetch(url).then((d) => d.text()); - - const node: Element = Array.from( - new DOMParser() - .parseFromString(res, "text/html") - .querySelectorAll(`script[id="__NEXT_DATA__"]`) - )[0]; - - let data = JSON.parse(node.innerHTML).props.pageProps.selectedTv.seasons; - - data = data.filter((season: any) => season.releaseDate != null); - data = data.map((season: any) => { - const episodes = season.episodes.filter( - (episode: any) => episode.releaseDate != null - ); - return { ...season, episodes }; - }); - - return { - seasons: data.map((d: any) => ({ - sort: d.seasonNumber === 0 ? 999 : d.seasonNumber, - id: d.seasonNumber.toString(), - type: d.seasonNumber === 0 ? "special" : "season", - title: d.name, - episodes: d.episodes.map((e: any) => ({ - title: e.name, - sort: e.episodeNumber, - id: e.episodeNumber.toString(), - episodeNumber: e.episodeNumber, - })), - })), - }; - }, -}; diff --git a/src/providers/list/theflix/portableToMedia.ts b/src/providers/list/theflix/portableToMedia.ts deleted file mode 100644 index 191e828c..00000000 --- a/src/providers/list/theflix/portableToMedia.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { conf } from "@/config"; -import { MWMediaType, MWPortableMedia } from "@/providers/types"; - -const getTheFlixUrl = (media: MWPortableMedia, params?: URLSearchParams) => { - if (media.mediaType === MWMediaType.MOVIE) { - return `https://theflix.to/movie/${media.mediaId}?${params}`; - } - if (media.mediaType === MWMediaType.SERIES) { - return `https://theflix.to/tv-show/${media.mediaId}/season-${media.seasonId}/episode-${media.episodeId}`; - } - - return ""; -}; - -export async function getDataFromPortableSearch( - media: MWPortableMedia -): Promise { - const params = new URLSearchParams(); - params.append("movieInfo", media.mediaId); - - const res = await fetch( - conf().CORS_PROXY_URL + getTheFlixUrl(media, params) - ).then((d) => d.text()); - - const node: Element = Array.from( - new DOMParser() - .parseFromString(res, "text/html") - .querySelectorAll(`script[id="__NEXT_DATA__"]`) - )[0]; - - if (media.mediaType === MWMediaType.MOVIE) { - return JSON.parse(node.innerHTML).props.pageProps.movie; - } - // must be series here - return JSON.parse(node.innerHTML).props.pageProps.selectedTv; -} diff --git a/src/providers/list/theflix/search.ts b/src/providers/list/theflix/search.ts deleted file mode 100644 index c0dded24..00000000 --- a/src/providers/list/theflix/search.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { conf } from "@/config"; -import { MWMediaType, MWProviderMediaResult, MWQuery } from "@/providers"; - -const getTheFlixUrl = (type: "tv-shows" | "movies", params: URLSearchParams) => - `https://theflix.to/${type}/trending?${params}`; - -export function searchTheFlix(query: MWQuery): Promise { - const params = new URLSearchParams(); - params.append("search", query.searchQuery); - return fetch( - conf().CORS_PROXY_URL + - getTheFlixUrl( - query.type === MWMediaType.MOVIE ? "movies" : "tv-shows", - params - ) - ).then((d) => d.text()); -} - -export function getDataFromSearch(page: string, limit = 10): any[] { - const node: Element = Array.from( - new DOMParser() - .parseFromString(page, "text/html") - .querySelectorAll(`script[id="__NEXT_DATA__"]`) - )[0]; - const data = JSON.parse(node.innerHTML); - return data.props.pageProps.mainList.docs - .filter((d: any) => d.available) - .slice(0, limit); -} - -export function turnDataIntoMedia(data: any): MWProviderMediaResult { - return { - mediaId: `${data.id}-${data.name - .replace(/[^a-z0-9]+|\s+/gim, " ") - .trim() - .replace(/\s+/g, "-") - .toLowerCase()}`, - title: data.name, - year: new Date(data.releaseDate).getFullYear().toString(), - seasonCount: data.numberOfSeasons, - episodeId: data.lastReleasedEpisode - ? data.lastReleasedEpisode.episodeNumber.toString() - : null, - seasonId: data.lastReleasedEpisode - ? data.lastReleasedEpisode.seasonNumber.toString() - : null, - }; -} diff --git a/src/providers/list/xemovie/index.ts b/src/providers/list/xemovie/index.ts deleted file mode 100644 index 2d14e73b..00000000 --- a/src/providers/list/xemovie/index.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { - MWMediaProvider, - MWMediaType, - MWPortableMedia, - MWMediaStream, - MWQuery, - MWProviderMediaResult, - MWMediaCaption, -} from "@/providers/types"; - -import { conf } from "@/config"; - -export const xemovieScraper: MWMediaProvider = { - id: "xemovie", - enabled: false, - type: [MWMediaType.MOVIE], - displayName: "xemovie", - - async getMediaFromPortable( - media: MWPortableMedia - ): Promise { - const res = await fetch( - `${conf().CORS_PROXY_URL}https://xemovie.co/movies/${media.mediaId}/watch` - ).then((d) => d.text()); - - const DOM = new DOMParser().parseFromString(res, "text/html"); - - const title = - DOM.querySelector(".text-primary.text-lg.font-extrabold")?.textContent || - ""; - const year = - DOM.querySelector("div.justify-between:nth-child(3) > div:nth-child(2)") - ?.textContent || ""; - - return { - ...media, - title, - year, - } as MWProviderMediaResult; - }, - - async searchForMedia(query: MWQuery): Promise { - const term = query.searchQuery.toLowerCase(); - - const searchUrl = `${ - conf().CORS_PROXY_URL - }https://xemovie.co/search?q=${encodeURIComponent(term)}`; - const searchRes = await fetch(searchUrl).then((d) => d.text()); - - const parser = new DOMParser(); - const doc = parser.parseFromString(searchRes, "text/html"); - - const movieContainer = doc - .querySelectorAll(".py-10")[0] - .querySelector(".grid"); - if (!movieContainer) return []; - const movieNodes = Array.from(movieContainer.querySelectorAll("a")).filter( - (link) => !link.className - ); - - const results: MWProviderMediaResult[] = movieNodes - .map((node) => { - const parent = node.parentElement; - if (!parent) return; - - const aElement = parent.querySelector("a"); - if (!aElement) return; - - return { - title: parent.querySelector("div > div > a > h6")?.textContent, - year: parent.querySelector("div.float-right")?.textContent, - mediaId: aElement.href.split("/").pop() || "", - }; - }) - .filter((d): d is MWProviderMediaResult => !!d); - - return results; - }, - - async getStream(media: MWPortableMedia): Promise { - if (media.mediaType !== MWMediaType.MOVIE) - throw new Error("Incorrect type"); - - const url = `${conf().CORS_PROXY_URL}https://xemovie.co/movies/${ - media.mediaId - }/watch`; - - let streamUrl = ""; - const subtitles: MWMediaCaption[] = []; - - const res = await fetch(url).then((d) => d.text()); - const scripts = Array.from( - new DOMParser() - .parseFromString(res, "text/html") - .querySelectorAll("script") - ); - - for (const script of scripts) { - if (!script.textContent) continue; - - if (script.textContent.match(/https:\/\/[a-z][0-9]\.xemovie\.com/)) { - const data = JSON.parse( - JSON.stringify( - eval( - `(${ - script.textContent.replace("const data = ", "").split("};")[0] - }})` - ) - ) - ); - streamUrl = data.playlist[0].file; - - for (const [ - index, - subtitleTrack, - ] of data.playlist[0].tracks.entries()) { - const subtitleBlob = URL.createObjectURL( - await fetch(`${conf().CORS_PROXY_URL}${subtitleTrack.file}`).then( - (captionRes) => captionRes.blob() - ) - ); // do this so no need for CORS errors - - subtitles.push({ - id: index, - url: subtitleBlob, - label: subtitleTrack.label, - }); - } - } - } - - const streamType = streamUrl.split(".").at(-1); - if (streamType !== "mp4" && streamType !== "m3u8") - throw new Error("Unsupported stream type"); - - return { - url: streamUrl, - type: streamType, - captions: subtitles, - } as MWMediaStream; - }, -}; diff --git a/src/providers/methods/contentCache.ts b/src/providers/methods/contentCache.ts deleted file mode 100644 index c2e43a7b..00000000 --- a/src/providers/methods/contentCache.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { SimpleCache } from "@/utils/cache"; -import { MWPortableMedia, MWMedia } from "@/providers"; - -// cache -const contentCache = new SimpleCache(); -contentCache.setCompare( - (a, b) => a.mediaId === b.mediaId && a.providerId === b.providerId -); -contentCache.initialize(); - -export default contentCache; diff --git a/src/providers/methods/helpers.ts b/src/providers/methods/helpers.ts deleted file mode 100644 index 4ab75794..00000000 --- a/src/providers/methods/helpers.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { MWMediaType, MWMediaProviderMetadata } from "@/providers"; -import { MWMedia, MWMediaEpisode, MWMediaSeason } from "@/providers/types"; -import { mediaProviders, mediaProvidersUnchecked } from "./providers"; - -/* - ** Fetch all enabled providers for a specific type - */ -export function GetProvidersForType(type: MWMediaType) { - return mediaProviders.filter((v) => v.type.includes(type)); -} - -/* - ** Get a provider by a id - */ -export function getProviderFromId(id: string) { - return mediaProviders.find((v) => v.id === id); -} - -/* - ** Get a provider metadata - */ -export function getProviderMetadata(id: string): MWMediaProviderMetadata { - const provider = mediaProvidersUnchecked.find((v) => v.id === id); - - if (!provider) { - return { - exists: false, - type: [], - enabled: false, - id, - }; - } - - return { - exists: true, - type: provider.type, - enabled: provider.enabled, - id, - provider, - }; -} - -/* - ** get episode and season from media - */ -export function getEpisodeFromMedia( - media: MWMedia -): { season: MWMediaSeason; episode: MWMediaEpisode } | null { - if ( - media.seasonId === undefined || - media.episodeId === undefined || - media.seriesData === undefined - ) { - return null; - } - - const season = media.seriesData.seasons.find((v) => v.id === media.seasonId); - if (!season) return null; - const episode = season?.episodes.find((v) => v.id === media.episodeId); - if (!episode) return null; - return { - season, - episode, - }; -} diff --git a/src/providers/methods/providers.ts b/src/providers/methods/providers.ts deleted file mode 100644 index c7d68875..00000000 --- a/src/providers/methods/providers.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { theFlixScraper } from "@/providers/list/theflix"; -import { gDrivePlayerScraper } from "@/providers/list/gdriveplayer"; -import { MWWrappedMediaProvider, WrapProvider } from "@/providers/wrapper"; -import { gomostreamScraper } from "@/providers/list/gomostream"; -import { xemovieScraper } from "@/providers/list/xemovie"; -import { flixhqProvider } from "@/providers/list/flixhq"; -import { superStreamScraper } from "@/providers/list/superstream"; - -export const mediaProvidersUnchecked: MWWrappedMediaProvider[] = [ - WrapProvider(superStreamScraper), - WrapProvider(theFlixScraper), - WrapProvider(gDrivePlayerScraper), - WrapProvider(gomostreamScraper), - WrapProvider(xemovieScraper), - WrapProvider(flixhqProvider), -]; - -export const mediaProviders: MWWrappedMediaProvider[] = - mediaProvidersUnchecked.filter((v) => v.enabled); diff --git a/src/providers/methods/search.ts b/src/providers/methods/search.ts deleted file mode 100644 index 856fc0fa..00000000 --- a/src/providers/methods/search.ts +++ /dev/null @@ -1,105 +0,0 @@ -import Fuse from "fuse.js"; -import { - MWMassProviderOutput, - MWMedia, - MWQuery, - convertMediaToPortable, -} from "@/providers"; -import { SimpleCache } from "@/utils/cache"; -import { GetProvidersForType } from "./helpers"; -import contentCache from "./contentCache"; - -// cache -const resultCache = new SimpleCache(); -resultCache.setCompare( - (a, b) => a.searchQuery === b.searchQuery && a.type === b.type -); -resultCache.initialize(); - -/* - ** actually call all providers with the search query - */ -async function callProviders(query: MWQuery): Promise { - const allQueries = GetProvidersForType(query.type).map< - Promise<{ media: MWMedia[]; success: boolean; id: string }> - >(async (provider) => { - try { - return { - media: await provider.searchForMedia(query), - success: true, - id: provider.id, - }; - } catch (err) { - console.error(`Failed running provider ${provider.id}`, err, query); - return { - media: [], - success: false, - id: provider.id, - }; - } - }); - const allResults = await Promise.all(allQueries); - const providerResults = allResults.map((provider) => ({ - success: provider.success, - id: provider.id, - })); - const output: MWMassProviderOutput = { - results: allResults.flatMap((results) => results.media), - providers: providerResults, - stats: { - total: providerResults.length, - failed: providerResults.filter((v) => !v.success).length, - succeeded: providerResults.filter((v) => v.success).length, - }, - }; - - // save in cache if all successfull - if (output.stats.failed === 0) { - resultCache.set(query, output, 60 * 60); // cache for an hour - } - - output.results.forEach((result: MWMedia) => { - contentCache.set(convertMediaToPortable(result), result, 60 * 60); - }); - - return output; -} - -/* - ** sort results based on query - */ -function sortResults( - query: MWQuery, - providerResults: MWMassProviderOutput -): MWMassProviderOutput { - const results: MWMassProviderOutput = { ...providerResults }; - const fuse = new Fuse(results.results, { - threshold: 0.3, - keys: ["title"], - fieldNormWeight: 0.5, - }); - results.results = fuse.search(query.searchQuery).map((v) => v.item); - return results; -} - -/* - ** Call search on all providers that matches query type - */ -export async function SearchProviders( - inputQuery: MWQuery -): Promise { - // input normalisation - const query = { ...inputQuery }; - query.searchQuery = query.searchQuery.toLowerCase().trim(); - - // consult cache first - let output = resultCache.get(query); - if (!output) output = await callProviders(query); - - // sort results - output = sortResults(query, output); - - if (output.stats.total === output.stats.failed) - throw new Error("All Scrapers failed"); - return output; -} diff --git a/src/providers/methods/seasons.ts b/src/providers/methods/seasons.ts deleted file mode 100644 index a9e996be..00000000 --- a/src/providers/methods/seasons.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { SimpleCache } from "@/utils/cache"; -import { MWPortableMedia } from "@/providers"; -import { - MWMediaSeasons, - MWMediaType, - MWMediaProviderSeries, -} from "@/providers/types"; -import { getProviderFromId } from "./helpers"; - -// cache -const seasonCache = new SimpleCache(); -seasonCache.setCompare( - (a, b) => a.mediaId === b.mediaId && a.providerId === b.providerId -); -seasonCache.initialize(); - -/* - ** get season data from a (portable) media object, seasons and episodes will be sorted - */ -export async function getSeasonDataFromMedia( - media: MWPortableMedia -): Promise { - const provider = getProviderFromId(media.providerId) as MWMediaProviderSeries; - if (!provider) { - return { - seasons: [], - }; - } - - if ( - !provider.type.includes(MWMediaType.SERIES) && - !provider.type.includes(MWMediaType.ANIME) - ) { - return { - seasons: [], - }; - } - - if (seasonCache.has(media)) { - return seasonCache.get(media) as MWMediaSeasons; - } - - const seasonData = await provider.getSeasonDataFromMedia(media); - seasonData.seasons.sort((a, b) => a.sort - b.sort); - seasonData.seasons.forEach((s) => s.episodes.sort((a, b) => a.sort - b.sort)); - - // cache it - seasonCache.set(media, seasonData, 60 * 60); // cache it for an hour - return seasonData; -} diff --git a/src/providers/types.ts b/src/providers/types.ts deleted file mode 100644 index d3c2cdd2..00000000 --- a/src/providers/types.ts +++ /dev/null @@ -1,97 +0,0 @@ -export enum MWMediaType { - MOVIE = "movie", - SERIES = "series", - ANIME = "anime", -} - -export interface MWPortableMedia { - mediaId: string; - mediaType: MWMediaType; - providerId: string; - seasonId?: string; - episodeId?: string; -} - -export type MWMediaStreamType = "m3u8" | "mp4"; -export interface MWMediaCaption { - id: string; - url: string; - label: string; -} -export interface MWMediaStream { - url: string; - type: MWMediaStreamType; - captions: MWMediaCaption[]; -} - -export interface MWMediaMeta extends MWPortableMedia { - title: string; - year: string; - seasonCount?: number; -} - -export interface MWMediaEpisode { - sort: number; - id: string; - title: string; -} -export interface MWMediaSeason { - sort: number; - id: string; - title?: string; - type: "season" | "special"; - episodes: MWMediaEpisode[]; -} -export interface MWMediaSeasons { - seasons: MWMediaSeason[]; -} - -export interface MWMedia extends MWMediaMeta { - seriesData?: MWMediaSeasons; -} - -export type MWProviderMediaResult = Omit; - -export interface MWQuery { - searchQuery: string; - type: MWMediaType; -} - -export interface MWMediaProviderBase { - id: string; // id of provider, must be unique - enabled: boolean; - type: MWMediaType[]; - displayName: string; - - getMediaFromPortable(media: MWPortableMedia): Promise; - searchForMedia(query: MWQuery): Promise; - getStream(media: MWPortableMedia): Promise; - getSeasonDataFromMedia?: (media: MWPortableMedia) => Promise; -} - -export type MWMediaProviderSeries = MWMediaProviderBase & { - getSeasonDataFromMedia: (media: MWPortableMedia) => Promise; -}; - -export type MWMediaProvider = MWMediaProviderBase; - -export interface MWMediaProviderMetadata { - exists: boolean; - id?: string; - enabled: boolean; - type: MWMediaType[]; - provider?: MWMediaProvider; -} - -export interface MWMassProviderOutput { - providers: { - id: string; - success: boolean; - }[]; - results: MWMedia[]; - stats: { - total: number; - failed: number; - succeeded: number; - }; -} diff --git a/src/providers/wrapper.ts b/src/providers/wrapper.ts deleted file mode 100644 index 35727d97..00000000 --- a/src/providers/wrapper.ts +++ /dev/null @@ -1,48 +0,0 @@ -import contentCache from "./methods/contentCache"; -import { - MWMedia, - MWMediaProvider, - MWMediaStream, - MWPortableMedia, - MWQuery, -} from "./types"; - -export interface MWWrappedMediaProvider extends MWMediaProvider { - getMediaFromPortable(media: MWPortableMedia): Promise; - searchForMedia(query: MWQuery): Promise; - getStream(media: MWPortableMedia): Promise; -} - -export function WrapProvider( - provider: MWMediaProvider -): MWWrappedMediaProvider { - return { - ...provider, - - async getMediaFromPortable(media: MWPortableMedia): Promise { - // consult cache first - const output = contentCache.get(media); - if (output) { - output.seasonId = media.seasonId; - output.episodeId = media.episodeId; - return output; - } - - const mediaObject = { - ...(await provider.getMediaFromPortable(media)), - providerId: provider.id, - mediaType: media.mediaType, - }; - contentCache.set(media, mediaObject, 60 * 60); - return mediaObject; - }, - - async searchForMedia(query: MWQuery): Promise { - return (await provider.searchForMedia(query)).map((m) => ({ - ...m, - providerId: provider.id, - mediaType: query.type, - })); - }, - }; -} diff --git a/src/App.tsx b/src/setup/App.tsx similarity index 58% rename from src/App.tsx rename to src/setup/App.tsx index b427c62e..50c89f97 100644 --- a/src/App.tsx +++ b/src/setup/App.tsx @@ -1,22 +1,28 @@ import { Redirect, Route, Switch } from "react-router-dom"; -import { MWMediaType } from "@/providers"; import { BookmarkContextProvider } from "@/state/bookmark"; import { WatchedContextProvider } from "@/state/watched"; + import { NotFoundPage } from "@/views/notfound/NotFoundView"; -import "./index.css"; -import { MediaView } from "./views/MediaView"; -import { SearchView } from "./views/SearchView"; +import { MediaView } from "@/views/media/MediaView"; +import { SearchView } from "@/views/search/SearchView"; +import { MWMediaType } from "@/backend/metadata/types"; +import { V2MigrationView } from "@/views/other/v2Migration"; function App() { return ( + - - + + diff --git a/src/setup/chromecast.ts b/src/setup/chromecast.ts new file mode 100644 index 00000000..9c288629 --- /dev/null +++ b/src/setup/chromecast.ts @@ -0,0 +1,30 @@ +const CHROMECAST_SENDER_SDK = + "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"; + +const callbacks: ((available: boolean) => void)[] = []; +let _available: boolean | null = null; + +function init(available: boolean) { + _available = available; + callbacks.forEach((cb) => cb(available)); +} + +export function isChromecastAvailable(cb: (available: boolean) => void) { + if (_available !== null) return cb(_available); + callbacks.push(cb); +} + +export function initializeChromecast() { + window.__onGCastApiAvailable = (isAvailable) => { + init(isAvailable); + }; + + // add script if doesnt exist yet + const exists = !!document.getElementById("chromecast-script"); + if (!exists) { + const script = document.createElement("script"); + script.setAttribute("src", CHROMECAST_SENDER_SDK); + script.setAttribute("id", "chromecast-script"); + document.body.appendChild(script); + } +} diff --git a/src/config.ts b/src/setup/config.ts similarity index 87% rename from src/config.ts rename to src/setup/config.ts index 6e4fb805..72a762f5 100644 --- a/src/config.ts +++ b/src/setup/config.ts @@ -1,4 +1,4 @@ -import { APP_VERSION, GITHUB_LINK, DISCORD_LINK } from "@/constants"; +import { APP_VERSION, GITHUB_LINK, DISCORD_LINK } from "./constants"; interface Config { APP_VERSION: string; @@ -7,6 +7,7 @@ interface Config { OMDB_API_KEY: string; TMDB_API_KEY: string; CORS_PROXY_URL: string; + NORMAL_ROUTER: boolean; } export interface RuntimeConfig extends Config { @@ -20,6 +21,7 @@ const env: Record = { GITHUB_LINK: undefined, DISCORD_LINK: undefined, CORS_PROXY_URL: import.meta.env.VITE_CORS_PROXY_URL, + NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER, }; const alerts = [] as string[]; @@ -51,5 +53,6 @@ export function conf(): RuntimeConfig { TMDB_API_KEY: getKey("TMDB_API_KEY"), BASE_PROXY_URL: getKey("CORS_PROXY_URL"), CORS_PROXY_URL: `${getKey("CORS_PROXY_URL")}/?destination=`, + NORMAL_ROUTER: (getKey("NORMAL_ROUTER") ?? "false") === "true", }; } diff --git a/src/mw_constants.ts b/src/setup/constants.ts similarity index 100% rename from src/mw_constants.ts rename to src/setup/constants.ts diff --git a/src/setup/i18n.ts b/src/setup/i18n.ts new file mode 100644 index 00000000..d243c0f5 --- /dev/null +++ b/src/setup/i18n.ts @@ -0,0 +1,30 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; + +// Languages +import en from "./locales/en/translation.json"; + +i18n + // detect user language + // learn more: https://github.com/i18next/i18next-browser-languageDetector + .use(LanguageDetector) + // pass the i18n instance to react-i18next. + .use(initReactI18next) + // init i18next + // for all options read: https://www.i18next.com/overview/configuration-options + .init({ + fallbackLng: "en", + + resources: { + en: { + translation: en, + }, + }, + + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + }, + }); + +export default i18n; diff --git a/src/setup/index.css b/src/setup/index.css new file mode 100644 index 00000000..c3d97c26 --- /dev/null +++ b/src/setup/index.css @@ -0,0 +1,55 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body { + @apply bg-denim-100 text-denim-700 font-open-sans overflow-x-hidden; + min-height: 100vh; + min-height: 100dvh; +} + +html[data-full], html[data-full] body { + overscroll-behavior-y: none; +} + +body[data-no-scroll] { + overflow-y: hidden; + height: 100vh; +} + +#root { + padding: 0.05px; + min-height: 100vh; + min-height: 100dvh; + width: 100%; +} + +body[data-no-select] { + user-select: none; +} + +.roll { + animation: roll 1s; +} + +@keyframes roll { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.line-clamp { + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.google-cast-button:not(.casting) google-cast-launcher { + @apply brightness-[500]; +} diff --git a/src/setup/locales/en/translation.json b/src/setup/locales/en/translation.json new file mode 100644 index 00000000..8842b58f --- /dev/null +++ b/src/setup/locales/en/translation.json @@ -0,0 +1,91 @@ +{ + "global": { + "name": "movie-web" + }, + "search": { + "loading_series": "Fetching your favourite series...", + "loading_movie": "Fetching your favourite movies...", + "loading": "Loading...", + "allResults": "That's all we have!", + "noResults": "We couldn't find anything!", + "allFailed": "Failed to find media, try again!", + "headingTitle": "Search results", + "bookmarks": "Bookmarks", + "continueWatching": "Continue Watching", + "title": "What do you want to watch?", + "placeholder": "What do you want to watch?" + }, + "media": { + "movie": "Movie", + "series": "Series", + "stopEditing": "Stop editing", + "errors": { + "genericTitle": "Whoops, it broke!", + "failedMeta": "Failed to load meta", + "mediaFailed": "We failed to request the media you asked for, check your internet connection and try again.", + "videoFailed": "We encountered an error while playing the video you requested. If this keeps happening please report the issue to the <0>Discord server or on <1>GitHub." + } + }, + "seasons": { + "seasonAndEpisode": "S{{season}} E{{episode}}" + }, + "notFound": { + "genericTitle": "Not found", + "backArrow": "Back to home", + "media": { + "title": "Couldn't find that media", + "description": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL" + }, + "provider": { + "title": "This provider has been disabled", + "description": "We had issues with the provider or it was too unstable to use, so we had to disable it." + }, + "page": { + "title": "Couldn't find that page", + "description": "We looked everywhere: under the bins, in the closet, behind the proxy but ultimately couldn't find the page you are looking for." + } + }, + "searchBar": { + "movie": "Movie", + "series": "Series", + "Search": "Search" + }, + "videoPlayer": { + "findingBestVideo": "Finding the best video for you", + "noVideos": "Whoops, couldn't find any videos for you", + "loading": "Loading...", + "backToHome": "Back to home", + "seasonAndEpisode": "S{{season}} E{{episode}}", + "buttons": { + "episodes": "Episodes", + "source": "Source", + "captions": "Captions" + }, + "popouts": { + "sources": "Sources", + "seasons": "Seasons", + "captions": "Captions", + "episode": "E{{index}} - {{title}}", + "noCaptions": "No captions", + "linkedCaptions": "Linked captions", + "noEmbeds": "No embeds were found for this source", + "errors": { + "loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}", + "embedsError": "Something went wrong loading the embeds for this thing that you like" + } + }, + "errors": { + "fatalError": "The video player encounted a fatal error, please report it to the <0>Discord server or on <1>GitHub." + } + }, + "v3": { + "newSiteTitle": "New version now released!", + "newDomain": "https://movie-web.app", + "newDomainText": "movie-web will soon be moving to a new domain: <0>https://movie-web.app. Make sure to update all your bookmarks as <1>the old website will stop working on {{date}}.", + "tireless": "We've worked tirelessly on this new update, we hope you will enjoy what we've been cooking up for the past months.", + "leaveAnnouncement": "Take me there!" + }, + "casting": { + "casting": "Casting to device..." + } +} diff --git a/src/state/bookmark/context.tsx b/src/state/bookmark/context.tsx index c8eb2dca..6252ceac 100644 --- a/src/state/bookmark/context.tsx +++ b/src/state/bookmark/context.tsx @@ -1,17 +1,8 @@ -import { - createContext, - ReactNode, - useCallback, - useContext, - useMemo, - useState, -} from "react"; -import { getProviderMetadata, MWMediaMeta } from "@/providers"; +import { MWMediaMeta } from "@/backend/metadata/types"; +import { useStore } from "@/utils/storage"; +import { createContext, ReactNode, useContext, useMemo } from "react"; import { BookmarkStore } from "./store"; - -interface BookmarkStoreData { - bookmarks: MWMediaMeta[]; -} +import { BookmarkStoreData } from "./types"; interface BookmarkStoreDataWrapper { setItemBookmark(media: MWMediaMeta, bookedmarked: boolean): void; @@ -31,68 +22,27 @@ function getBookmarkIndexFromMedia( bookmarks: MWMediaMeta[], media: MWMediaMeta ): number { - const a = bookmarks.findIndex( - (v) => - v.mediaId === media.mediaId && - v.providerId === media.providerId && - v.episodeId === media.episodeId && - v.seasonId === media.seasonId - ); + const a = bookmarks.findIndex((v) => v.id === media.id); return a; } export function BookmarkContextProvider(props: { children: ReactNode }) { - const bookmarkLocalstorage = BookmarkStore.get(); - const [bookmarkStorage, setBookmarkStore] = useState( - bookmarkLocalstorage as BookmarkStoreData - ); - - const setBookmarked = useCallback( - (data: any) => { - setBookmarkStore((old) => { - const old2 = JSON.parse(JSON.stringify(old)); - let newData = data; - if (data.constructor === Function) { - newData = data(old2); - } - bookmarkLocalstorage.save(newData); - return newData; - }); - }, - [bookmarkLocalstorage, setBookmarkStore] - ); + const [bookmarkStorage, setBookmarked] = useStore(BookmarkStore); const contextValue = useMemo( () => ({ setItemBookmark(media: MWMediaMeta, bookmarked: boolean) { - setBookmarked((data: BookmarkStoreData) => { - if (bookmarked) { - const itemIndex = getBookmarkIndexFromMedia(data.bookmarks, media); - if (itemIndex === -1) { - const item = { - mediaId: media.mediaId, - mediaType: media.mediaType, - providerId: media.providerId, - title: media.title, - year: media.year, - episodeId: media.episodeId, - seasonId: media.seasonId, - }; - data.bookmarks.push(item); - } - } else { - const itemIndex = getBookmarkIndexFromMedia(data.bookmarks, media); - if (itemIndex !== -1) { - data.bookmarks.splice(itemIndex); - } - } - return data; + setBookmarked((data: BookmarkStoreData): BookmarkStoreData => { + let bookmarks = [...data.bookmarks]; + bookmarks = bookmarks.filter((v) => v.id !== media.id); + if (bookmarked) bookmarks.push({ ...media }); + return { + bookmarks, + }; }); }, getFilteredBookmarks() { - return bookmarkStorage.bookmarks.filter( - (bookmark) => getProviderMetadata(bookmark.providerId)?.enabled - ); + return [...bookmarkStorage.bookmarks]; }, bookmarkStore: bookmarkStorage, }), diff --git a/src/state/bookmark/index.ts b/src/state/bookmark/index.ts index 1b3fa9eb..2edd280c 100644 --- a/src/state/bookmark/index.ts +++ b/src/state/bookmark/index.ts @@ -1 +1 @@ -export * from "./context"; \ No newline at end of file +export * from "./context"; diff --git a/src/state/bookmark/store.ts b/src/state/bookmark/store.ts index 17f06642..11bec1c5 100644 --- a/src/state/bookmark/store.ts +++ b/src/state/bookmark/store.ts @@ -1,9 +1,17 @@ -import { versionedStoreBuilder } from "@/utils/storage"; +import { createVersionedStore } from "@/utils/storage"; +import { migrateV1Bookmarks, OldBookmarks } from "../watched/migrations/v2"; +import { BookmarkStoreData } from "./types"; -export const BookmarkStore = versionedStoreBuilder() +export const BookmarkStore = createVersionedStore() .setKey("mw-bookmarks") .addVersion({ version: 0, + migrate(oldBookmarks: OldBookmarks) { + return migrateV1Bookmarks(oldBookmarks); + }, + }) + .addVersion({ + version: 1, create() { return { bookmarks: [], diff --git a/src/state/bookmark/types.ts b/src/state/bookmark/types.ts new file mode 100644 index 00000000..05cb3641 --- /dev/null +++ b/src/state/bookmark/types.ts @@ -0,0 +1,5 @@ +import { MWMediaMeta } from "@/backend/metadata/types"; + +export interface BookmarkStoreData { + bookmarks: MWMediaMeta[]; +} diff --git a/src/state/watched/context.tsx b/src/state/watched/context.tsx index 789d2308..f45baf71 100644 --- a/src/state/watched/context.tsx +++ b/src/state/watched/context.tsx @@ -1,140 +1,130 @@ -import React, { +import { DetailedMeta } from "@/backend/metadata/getmeta"; +import { MWMediaType } from "@/backend/metadata/types"; +import { useStore } from "@/utils/storage"; +import { createContext, ReactNode, useCallback, useContext, useMemo, - useState, + useRef, } from "react"; -import { MWMediaMeta, getProviderMetadata, MWMediaType } from "@/providers"; import { VideoProgressStore } from "./store"; +import { StoreMediaItem, WatchedStoreItem, WatchedStoreData } from "./types"; -interface WatchedStoreItem extends MWMediaMeta { - progress: number; - percentage: number; -} +const FIVETEEN_MINUTES = 15 * 60; +const FIVE_MINUTES = 5 * 60; -export interface WatchedStoreData { - items: WatchedStoreItem[]; +function shouldSave( + time: number, + duration: number, + isSeries: boolean +): boolean { + const timeFromEnd = Math.max(0, duration - time); + + // short movie + if (duration < FIVETEEN_MINUTES) { + if (time < 5) return false; + if (timeFromEnd < 60) return false; + return true; + } + + // long movie + if (time < 30) return false; + if (timeFromEnd < FIVE_MINUTES && !isSeries) return false; + return true; } interface WatchedStoreDataWrapper { - updateProgress(media: MWMediaMeta, progress: number, total: number): void; + updateProgress(media: StoreMediaItem, progress: number, total: number): void; getFilteredWatched(): WatchedStoreItem[]; + removeProgress(id: string): void; watched: WatchedStoreData; } -export function getWatchedFromPortable( - items: WatchedStoreItem[], - media: MWMediaMeta -): WatchedStoreItem | undefined { - return items.find( - (v) => - v.mediaId === media.mediaId && - v.providerId === media.providerId && - v.episodeId === media.episodeId && - v.seasonId === media.seasonId - ); -} - const WatchedContext = createContext({ updateProgress: () => {}, getFilteredWatched: () => [], + removeProgress: () => {}, watched: { items: [], }, }); WatchedContext.displayName = "WatchedContext"; -export function WatchedContextProvider(props: { children: ReactNode }) { - const watchedLocalstorage = VideoProgressStore.get(); - const [watched, setWatchedReal] = useState( - watchedLocalstorage as WatchedStoreData +function isSameEpisode(media: StoreMediaItem, v: StoreMediaItem) { + return ( + media.meta.id === v.meta.id && + (!media.series || + (media.series.seasonId === v.series?.seasonId && + media.series.episodeId === v.series?.episodeId)) ); +} - const setWatched = useCallback( - (data: any) => { - setWatchedReal((old) => { - let newData = data; - if (data.constructor === Function) { - newData = data(old); - } - watchedLocalstorage.save(newData); - return newData; - }); - }, - [setWatchedReal, watchedLocalstorage] - ); +export function WatchedContextProvider(props: { children: ReactNode }) { + const [watched, setWatched] = useStore(VideoProgressStore); const contextValue = useMemo( () => ({ + removeProgress(id: string) { + setWatched((data: WatchedStoreData) => { + const newData = { ...data }; + newData.items = newData.items.filter((v) => v.item.meta.id !== id); + return newData; + }); + }, updateProgress( - media: MWMediaMeta, + media: StoreMediaItem, progress: number, total: number ): void { setWatched((data: WatchedStoreData) => { - let item = getWatchedFromPortable(data.items, media); + const newData = { ...data }; + let item = newData.items.find((v) => isSameEpisode(media, v.item)); if (!item) { item = { - mediaId: media.mediaId, - mediaType: media.mediaType, - providerId: media.providerId, - title: media.title, - year: media.year, - percentage: 0, + item: { + ...media, + meta: { ...media.meta }, + series: media.series ? { ...media.series } : undefined, + }, progress: 0, - episodeId: media.episodeId, - seasonId: media.seasonId, + percentage: 0, + watchedAt: Date.now(), }; - data.items.push(item); + newData.items.push(item); } - // update actual item item.progress = progress; item.percentage = Math.round((progress / total) * 100); + item.watchedAt = Date.now(); - return data; + // remove item if shouldnt save + if (!shouldSave(progress, total, !!media.series)) { + newData.items = data.items.filter( + (v) => !isSameEpisode(v.item, media) + ); + } + + return newData; }); }, getFilteredWatched() { - // remove disabled providers - let filtered = watched.items.filter( - (item) => getProviderMetadata(item.providerId)?.enabled - ); + let filtered = watched.items; - // get highest episode number for every anime/season - const highestEpisode: Record = {}; - const highestWatchedItem: Record = {}; - filtered = filtered.filter((item) => { - if ( - [MWMediaType.ANIME, MWMediaType.SERIES].includes(item.mediaType) - ) { - const key = `${item.mediaType}-${item.mediaId}`; - const current: [number, number] = [ - item.episodeId ? parseInt(item.episodeId, 10) : -1, - item.seasonId ? parseInt(item.seasonId, 10) : -1, - ]; - let existing = highestEpisode[key]; - if (!existing) { - existing = current; - highestEpisode[key] = current; - highestWatchedItem[key] = item; - } - - if ( - current[0] > existing[0] || - (current[0] === existing[0] && current[1] > existing[1]) - ) { - highestEpisode[key] = current; - highestWatchedItem[key] = item; - } - return false; - } - return true; - }); - - return [...filtered, ...Object.values(highestWatchedItem)]; + // get most recently watched for every single item + const alreadyFoundMedia: string[] = []; + filtered = filtered + .sort((a, b) => { + return b.watchedAt - a.watchedAt; + }) + .filter((item) => { + const mediaId = item.item.meta.id; + if (alreadyFoundMedia.includes(mediaId)) return false; + alreadyFoundMedia.push(mediaId); + return true; + }); + return filtered; }, watched, }), @@ -142,7 +132,7 @@ export function WatchedContextProvider(props: { children: ReactNode }) { ); return ( - + {props.children} ); @@ -151,3 +141,62 @@ export function WatchedContextProvider(props: { children: ReactNode }) { export function useWatchedContext() { return useContext(WatchedContext); } + +function isSameEpisodeMeta( + media: StoreMediaItem, + mediaTwo: DetailedMeta | null, + episodeId?: string +) { + if (mediaTwo?.meta.type === MWMediaType.SERIES && episodeId) { + return isSameEpisode(media, { + meta: mediaTwo.meta, + series: { + season: 0, + episode: 0, + episodeId, + seasonId: mediaTwo.meta.seasonData.id, + }, + }); + } + if (!mediaTwo) return () => false; + return isSameEpisode(media, { meta: mediaTwo.meta }); +} + +export function useWatchedItem(meta: DetailedMeta | null, episodeId?: string) { + const { watched, updateProgress } = useContext(WatchedContext); + const item = useMemo( + () => watched.items.find((v) => isSameEpisodeMeta(v.item, meta, episodeId)), + [watched, meta, episodeId] + ); + const lastCommitedTime = useRef([0, 0]); + + const callback = useCallback( + (progress: number, total: number) => { + const hasChanged = + lastCommitedTime.current[0] !== progress || + lastCommitedTime.current[1] !== total; + if (meta && hasChanged) { + lastCommitedTime.current = [progress, total]; + const obj = { + meta: meta.meta, + series: + meta.meta.type === MWMediaType.SERIES && episodeId + ? { + seasonId: meta.meta.seasonData.id, + episodeId, + season: meta.meta.seasonData.number, + episode: + meta.meta.seasonData.episodes.find( + (ep) => ep.id === episodeId + )?.number || 0, + } + : undefined, + }; + updateProgress(obj, progress, total); + } + }, + [meta, updateProgress, episodeId] + ); + + return { updateProgress: callback, watchedItem: item }; +} diff --git a/src/state/watched/migrations/v2.ts b/src/state/watched/migrations/v2.ts new file mode 100644 index 00000000..de3dad44 --- /dev/null +++ b/src/state/watched/migrations/v2.ts @@ -0,0 +1,216 @@ +import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta"; +import { searchForMedia } from "@/backend/metadata/search"; +import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types"; +import { compareTitle } from "@/utils/titleMatch"; +import { WatchedStoreData, WatchedStoreItem } from "../types"; + +interface OldMediaBase { + mediaId: number; + mediaType: MWMediaType; + percentage: number; + progress: number; + providerId: string; + title: string; + year: number; +} + +interface OldMovie extends OldMediaBase { + mediaType: MWMediaType.MOVIE; +} + +interface OldSeries extends OldMediaBase { + mediaType: MWMediaType.SERIES; + episodeId: number; + seasonId: number; +} + +export interface OldData { + items: (OldMovie | OldSeries)[]; +} + +export interface OldBookmarks { + bookmarks: (OldMovie | OldSeries)[]; +} + +async function getMetas( + uniqueMedias: Record, + oldData?: OldData +): Promise> | undefined> { + const yearsAreClose = (a: number, b: number) => { + return Math.abs(a - b) <= 1; + }; + + const mediaMetas: Record> = {}; + + const relevantItems = await Promise.all( + Object.values(uniqueMedias).map(async (item) => { + const year = Number(item.year.toString().split("-")[0]); + const data = await searchForMedia({ + searchQuery: `${item.title} ${year}`, + type: item.mediaType, + }); + const relevantItem = data.find( + (res) => + yearsAreClose(Number(res.year), year) && + compareTitle(res.title, item.title) + ); + if (!relevantItem) { + console.error(`No item found for migration: ${item.title}`); + return; + } + return { + id: item.mediaId, + data: relevantItem, + }; + }) + ); + + for (const item of relevantItems.filter(Boolean)) { + if (!item) continue; + + let keys: (string | null)[][] = [["0", "0"]]; + if (item.data.type === "series") { + const meta = await getMetaFromId(item.data.type, item.data.id); + if (!meta || !meta?.meta.seasons) return; + const seasonNumbers = [ + ...new Set( + oldData?.items + ? oldData.items + .filter((watchedEntry: any) => watchedEntry.mediaId === item.id) + .map((watchedEntry: any) => watchedEntry.seasonId) + : ["0"] + ), + ]; + const seasons = seasonNumbers.map((num) => ({ + num, + season: meta.meta?.seasons?.[Math.max(0, (num as number) - 1)], + })); + keys = seasons + .map((season) => (season ? [season.num, season?.season?.id] : [])) + .filter((entry) => entry.length > 0); + } + + if (!mediaMetas[item.id]) mediaMetas[item.id] = {}; + await Promise.all( + keys.map(async ([key, id]) => { + if (!key) return; + mediaMetas[item.id][key] = await getMetaFromId( + item.data.type, + item.data.id, + id === "0" || id === null ? undefined : id + ); + }) + ); + } + + return mediaMetas; +} + +export async function migrateV1Bookmarks(old: OldBookmarks) { + const oldData = old; + if (!oldData) return; + + const uniqueMedias: Record = {}; + oldData.bookmarks.forEach((item: any) => { + if (uniqueMedias[item.mediaId]) return; + uniqueMedias[item.mediaId] = item; + }); + + const mediaMetas = await getMetas(uniqueMedias); + if (!mediaMetas) return; + + const bookmarks = Object.keys(mediaMetas) + .map((key) => mediaMetas[key]["0"]) + .map((t) => t?.meta) + .filter(Boolean); + + return { + bookmarks, + }; +} + +export async function migrateV2Videos(old: OldData) { + const oldData = old; + if (!oldData) return; + + const uniqueMedias: Record = {}; + oldData.items.forEach((item: any) => { + if (uniqueMedias[item.mediaId]) return; + uniqueMedias[item.mediaId] = item; + }); + + const mediaMetas = await getMetas(uniqueMedias, oldData); + if (!mediaMetas) return; + + // We've got all the metadata you can dream of now + // Now let's convert stuff into the new format. + const newData: WatchedStoreData = { + ...oldData, + items: [], + }; + + const now = Date.now(); + + for (const oldWatched of oldData.items) { + if (oldWatched.mediaType === "movie") { + if (!mediaMetas[oldWatched.mediaId]["0"]?.meta) continue; + + const newItem: WatchedStoreItem = { + item: { + meta: mediaMetas[oldWatched.mediaId]["0"]?.meta as MWMediaMeta, + }, + progress: oldWatched.progress, + percentage: oldWatched.percentage, + watchedAt: Date.now(), // There was no watchedAt in V2 + }; + + oldData.items = oldData.items.filter( + (item) => JSON.stringify(item) !== JSON.stringify(oldWatched) + ); + newData.items.push(newItem); + } else if (oldWatched.mediaType === "series") { + if (!mediaMetas[oldWatched.mediaId][oldWatched.seasonId]?.meta) continue; + + const meta = mediaMetas[oldWatched.mediaId][oldWatched.seasonId] + ?.meta as MWMediaMeta; + + if (meta.type !== "series") return; + + const newItem: WatchedStoreItem = { + item: { + meta, + series: { + episode: Number(oldWatched.episodeId), + season: Number(oldWatched.seasonId), + seasonId: meta.seasonData.id, + episodeId: + meta.seasonData.episodes[Number(oldWatched.episodeId) - 1].id, + }, + }, + progress: oldWatched.progress, + percentage: oldWatched.percentage, + watchedAt: + now + + Number(oldWatched.seasonId) * 1000 + + Number(oldWatched.episodeId), // There was no watchedAt in V2 + // JANK ALERT: Put watchedAt in the future to show last episode as most recently + }; + + if ( + newData.items.find( + (item) => + item.item.meta.id === newItem.item.meta.id && + item.item.series?.episodeId === newItem.item.series?.episodeId + ) + ) + continue; + + oldData.items = oldData.items.filter( + (item) => JSON.stringify(item) !== JSON.stringify(oldWatched) + ); + newData.items.push(newItem); + } + } + + return newData; +} diff --git a/src/state/watched/store.ts b/src/state/watched/store.ts index 0b3a79f7..84eefd67 100644 --- a/src/state/watched/store.ts +++ b/src/state/watched/store.ts @@ -1,90 +1,25 @@ -import { MWMediaType } from "@/providers"; -import { versionedStoreBuilder } from "@/utils/storage"; -import { WatchedStoreData } from "./context"; +import { createVersionedStore } from "@/utils/storage"; +import { migrateV2Videos, OldData } from "./migrations/v2"; +import { WatchedStoreData } from "./types"; -export const VideoProgressStore = versionedStoreBuilder() +export const VideoProgressStore = createVersionedStore() .setKey("video-progress") .addVersion({ version: 0, + migrate() { + return { + items: [], // dont migrate from version 0 to version 1, unmigratable + }; + }, }) .addVersion({ version: 1, - migrate(data: any) { - const output: WatchedStoreData = { items: [] }; - - if (!data || data.constructor !== Object) return output; - - Object.keys(data).forEach((scraperId) => { - if (scraperId === "--version") return; - if (scraperId === "save") return; - - if ( - data[scraperId].movie && - data[scraperId].movie.constructor === Object - ) { - Object.keys(data[scraperId].movie).forEach((movieId) => { - try { - output.items.push({ - mediaId: movieId.includes("player.php") - ? movieId.split("player.php%3Fimdb%3D")[1] - : movieId, - mediaType: MWMediaType.MOVIE, - providerId: scraperId, - title: data[scraperId].movie[movieId].full.meta.title, - year: data[scraperId].movie[movieId].full.meta.year, - progress: data[scraperId].movie[movieId].full.currentlyAt, - percentage: Math.round( - (data[scraperId].movie[movieId].full.currentlyAt / - data[scraperId].movie[movieId].full.totalDuration) * - 100 - ), - }); - } catch (err) { - console.error( - `Failed to migrate movie: ${scraperId}/${movieId}`, - data[scraperId].movie[movieId] - ); - } - }); - } - - if ( - data[scraperId].show && - data[scraperId].show.constructor === Object - ) { - Object.keys(data[scraperId].show).forEach((showId) => { - if (data[scraperId].show[showId].constructor !== Object) return; - Object.keys(data[scraperId].show[showId]).forEach((episodeId) => { - try { - output.items.push({ - mediaId: showId, - mediaType: MWMediaType.SERIES, - providerId: scraperId, - title: data[scraperId].show[showId][episodeId].meta.title, - year: data[scraperId].show[showId][episodeId].meta.year, - percentage: Math.round( - (data[scraperId].show[showId][episodeId].currentlyAt / - data[scraperId].show[showId][episodeId].totalDuration) * - 100 - ), - progress: data[scraperId].show[showId][episodeId].currentlyAt, - episodeId: - data[scraperId].show[showId][episodeId].show.episode, - seasonId: data[scraperId].show[showId][episodeId].show.season, - }); - } catch (err) { - console.error( - `Failed to migrate series: ${scraperId}/${showId}/${episodeId}`, - data[scraperId].show[showId][episodeId] - ); - } - }); - }); - } - }); - - return output; + async migrate(old: OldData) { + return migrateV2Videos(old); }, + }) + .addVersion({ + version: 2, create() { return { items: [], diff --git a/src/state/watched/types.ts b/src/state/watched/types.ts new file mode 100644 index 00000000..a3246c38 --- /dev/null +++ b/src/state/watched/types.ts @@ -0,0 +1,22 @@ +import { MWMediaMeta } from "@/backend/metadata/types"; + +export interface StoreMediaItem { + meta: MWMediaMeta; + series?: { + episodeId: string; + seasonId: string; + episode: number; + season: number; + }; +} + +export interface WatchedStoreItem { + item: StoreMediaItem; + progress: number; + percentage: number; + watchedAt: number; +} + +export interface WatchedStoreData { + items: WatchedStoreItem[]; +} diff --git a/src/utils/cache.ts b/src/utils/cache.ts index 383a660e..f5c3be3b 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -1,15 +1,15 @@ export class SimpleCache { protected readonly INTERVAL_MS = 2 * 60 * 1000; // 2 minutes - protected _interval: NodeJS.Timer | null = null; + protected _interval: ReturnType | null = null; protected _compare: ((a: Key, b: Key) => boolean) | null = null; protected _storage: { key: Key; value: Value; expiry: Date }[] = []; /* - ** initialize store, will start the interval - */ + ** initialize store, will start the interval + */ public initialize(): void { if (this._interval) throw new Error("cache is already initialized"); this._interval = setInterval(() => { @@ -22,46 +22,48 @@ export class SimpleCache { } /* - ** destroy cache instance, its not safe to use the instance after calling this - */ + ** destroy cache instance, its not safe to use the instance after calling this + */ public destroy(): void { - if (this._interval) - clearInterval(this._interval); + if (this._interval) clearInterval(this._interval); this.clear(); } - + /* - ** Set compare function, function must return true if A & B are equal - */ + ** Set compare function, function must return true if A & B are equal + */ public setCompare(compare: (a: Key, b: Key) => boolean): void { this._compare = compare; } /* - ** check if cache contains the item - */ + ** check if cache contains the item + */ public has(key: Key): boolean { return !!this.get(key); } - + /* - ** get item from cache - */ + ** get item from cache + */ public get(key: Key): Value | undefined { if (!this._compare) throw new Error("Compare function not set"); - const foundValue = this._storage.find(item => this._compare && this._compare(item.key, key)); - if (!foundValue) - return undefined; + const foundValue = this._storage.find( + (item) => this._compare && this._compare(item.key, key) + ); + if (!foundValue) return undefined; return foundValue.value; } - + /* - ** set item from cache, if it already exists, it will overwrite - */ + ** set item from cache, if it already exists, it will overwrite + */ public set(key: Key, value: Value, expirySeconds: number): void { if (!this._compare) throw new Error("Compare function not set"); - const foundValue = this._storage.find(item => this._compare && this._compare(item.key, key)); - const expiry = new Date((new Date().getTime()) + (expirySeconds * 1000)); + const foundValue = this._storage.find( + (item) => this._compare && this._compare(item.key, key) + ); + const expiry = new Date(new Date().getTime() + expirySeconds * 1000); // overwrite old value if (foundValue) { @@ -76,12 +78,12 @@ export class SimpleCache { key, value, expiry, - }) + }); } /* - ** remove item from cache - */ + ** remove item from cache + */ public remove(key: Key): void { if (!this._compare) throw new Error("Compare function not set"); this._storage.filter((val) => { @@ -89,10 +91,10 @@ export class SimpleCache { return true; }); } - + /* - ** clear entire cache storage - */ + ** clear entire cache storage + */ public clear(): void { this._storage = []; } diff --git a/src/utils/detectFeatures.ts b/src/utils/detectFeatures.ts new file mode 100644 index 00000000..15be4c69 --- /dev/null +++ b/src/utils/detectFeatures.ts @@ -0,0 +1,40 @@ +import fscreen from "fscreen"; + +export const isSafari = /^((?!chrome|android).)*safari/i.test( + navigator.userAgent +); + +let cachedVolumeResult: boolean | null = null; +export async function canChangeVolume(): Promise { + if (cachedVolumeResult === null) { + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => resolve(false), 1e3); + }); + const promise = new Promise((resolve) => { + const video = document.createElement("video"); + const handler = () => { + video.removeEventListener("volumechange", handler); + resolve(true); + }; + + video.addEventListener("volumechange", handler); + + video.volume = 0.5; + }); + + cachedVolumeResult = await Promise.race([promise, timeoutPromise]); + } + return cachedVolumeResult; +} + +export function canFullscreenAnyElement(): boolean { + return fscreen.fullscreenEnabled; +} + +export function canWebkitFullscreen(): boolean { + return canFullscreenAnyElement() || isSafari; +} + +export function canFullscreen(): boolean { + return canFullscreenAnyElement() || canWebkitFullscreen(); +} diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 51befa7a..349a8707 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -1,232 +1,195 @@ -// TODO make type and react safe!! -/* - it needs to be react-ified by having a save function not on the instance itself. - also type safety is important, this is all spaghetti with "any" everywhere -*/ +import { useEffect, useState } from "react"; -function buildStoreObject(d: any) { - const data: any = { - versions: d.versions, - currentVersion: d.maxVersion, - id: d.storageString, +interface StoreVersion { + version: number; + migrate?(data: A): any; + create?: () => A; +} +interface StoreRet { + save: (data: T) => void; + get: () => T; + _raw: () => any; + onChange: (cb: (data: T) => void) => { + destroy: () => void; }; +} - function update(this: any, obj2: any) { - let obj = obj2; - if (!obj) throw new Error("object to update is not an object"); +export interface StoreBuilder { + setKey: (key: string) => StoreBuilder; + addVersion: (ver: StoreVersion) => StoreBuilder; + build: () => StoreRet; +} - // repeat until object fully updated - if (obj["--version"] === undefined) obj["--version"] = 0; - while (obj["--version"] !== this.currentVersion) { - // get version - let version: any = obj["--version"] || 0; - if (version.constructor !== Number || version < 0) version = -42; - // invalid on purpose so it will reset - else { - version = ((version as number) + 1).toString(); +interface InternalStoreData { + versions: StoreVersion[]; + key: string | null; +} + +const storeCallbacks: Record void)[]> = {}; +const stores: Record, InternalStoreData]> = {}; + +export async function initializeStores() { + // migrate all stores + for (const [store, internal] of Object.values(stores)) { + const versions = internal.versions.sort((a, b) => a.version - b.version); + + const data = store._raw(); + const dataVersion = + data["--version"] && typeof data["--version"] === "number" + ? data["--version"] + : 0; + + // Find which versions need to be used for migrations + const relevantVersions = versions.filter((v) => v.version >= dataVersion); + + // Migrate over each version + let mostRecentData = data; + try { + for (const version of relevantVersions) { + if (version.migrate) + mostRecentData = await version.migrate(mostRecentData); } - - // check if version exists - if (!this.versions[version]) { - console.error( - `Version not found for storage item in store ${this.id}, resetting` - ); - obj = null; - break; - } - - // update object - obj = this.versions[version].update(obj); + } catch (err) { + console.error(`FAILED TO MIGRATE STORE ${internal.key}`, err); + // reset store to lastest version create + mostRecentData = + relevantVersions[relevantVersions.length - 1].create?.() ?? {}; } - // if resulting obj is null, use latest version as init object - if (obj === null) { - console.error( - `Storage item for store ${this.id} has been reset due to faulty updates` - ); - return this.versions[this.currentVersion.toString()].init(); - } - - // updates succesful, return - return obj; + store.save(mostRecentData); } +} - function get(this: any) { - // get from storage api - const store = this; - let gottenData: any = localStorage.getItem(this.id); +function buildStorageObject(store: InternalStoreData): StoreRet { + const key = store.key ?? ""; + const latestVersion = store.versions.sort((a, b) => b.version - a.version)[0]; - // parse json if item exists - if (gottenData) { - try { - gottenData = JSON.parse(gottenData); - if (!gottenData.constructor) { - console.error( - `Storage item for store ${this.id} has not constructor` - ); - throw new Error("storage item has no constructor"); - } - if (gottenData.constructor !== Object) { - console.error(`Storage item for store ${this.id} is not an object`); - throw new Error("storage item is not an object"); - } - } catch (_) { - // if errored, set to null so it generates new one, see below - console.error(`Failed to parse storage item for store ${this.id}`); - gottenData = null; - } - } - - // if item doesnt exist, generate from version init - if (!gottenData) { - gottenData = this.versions[this.currentVersion.toString()].init(); - } - - // update the data if needed - gottenData = this.update(gottenData); - - // add a save object to return value - gottenData.save = function save(newData: any) { - const dataToStore = newData || gottenData; - localStorage.setItem(store.id, JSON.stringify(dataToStore)); + function onChange(cb: (data: T) => void) { + if (!storeCallbacks[key]) storeCallbacks[key] = []; + storeCallbacks[key].push(cb); + return { + destroy() { + // remove function pointer from callbacks + storeCallbacks[key] = storeCallbacks[key].filter((v) => v === cb); + }, }; - - // add instance helpers - Object.entries(d.instanceHelpers).forEach(([name, helper]: any) => { - if (gottenData[name] !== undefined) - throw new Error( - `helper name: ${name} on instance of store ${this.id} is reserved` - ); - gottenData[name] = helper.bind(gottenData); - }); - - // return data - return gottenData; } - // add functions to store - data.get = get.bind(data); - data.update = update.bind(data); + function makeRaw() { + const data = latestVersion.create?.() ?? {}; + data["--version"] = latestVersion.version; + return data; + } - // add static helpers - Object.entries(d.staticHelpers).forEach(([name, helper]: any) => { - if (data[name] !== undefined) - throw new Error(`helper name: ${name} on store ${data.id} is reserved`); - data[name] = helper.bind({}); + function getRaw() { + const item = localStorage.getItem(key); + if (!item) return makeRaw(); + try { + return JSON.parse(item); + } catch (err) { + // we assume user has fucked with the data, give them a fresh store + console.error(`FAILED TO PARSE LOCALSTORAGE FOR KEY ${key}`, err); + return makeRaw(); + } + } + + function save(data: T) { + const withVersion: any = { ...data }; + withVersion["--version"] = latestVersion.version; + localStorage.setItem(key, JSON.stringify(withVersion)); + + if (!storeCallbacks[key]) storeCallbacks[key] = []; + storeCallbacks[key].forEach((v) => v(structuredClone(data))); + } + + return { + get() { + const data = getRaw(); + delete data["--version"]; + return data as T; + }, + _raw() { + return getRaw(); + }, + onChange, + save, + }; +} + +function assertStore(store: InternalStoreData) { + const versionListSorted = store.versions.sort( + (a, b) => a.version - b.version + ); + versionListSorted.forEach((v, i, arr) => { + if (i === 0) return; + if (v.version !== arr[i - 1].version + 1) + throw new Error("Version list of store is not incremental"); + }); + versionListSorted.forEach((v) => { + if (v.version < 0) throw new Error("Versions cannot be negative"); }); - return data; + // version zero must exist + if (versionListSorted[0]?.version !== 0) + throw new Error("Version 0 doesn't exist in version list of store"); + + // max version must have create function + if (!store.versions[store.versions.length - 1].create) + throw new Error(`Missing create function on latest version of store`); + + // check storage string + if (!store.key) throw new Error("storage key not set in store"); + + // check if all parts have migratio + const migrations = [...versionListSorted]; + migrations.pop(); + migrations.forEach((v) => { + if (!v.migrate) + throw new Error(`Migration missing on version ${v.version}`); + }); } -/* - * Builds a versioned store - * - * manages versioning of localstorage items - */ -export function versionedStoreBuilder(): any { +export function createVersionedStore(): StoreBuilder { + const _data: InternalStoreData = { + versions: [], + key: null, + }; + return { - _data: { - versionList: [], - maxVersion: 0, - versions: {}, - storageString: undefined, - instanceHelpers: {}, - staticHelpers: {}, - }, - - setKey(str: string) { - this._data.storageString = str; + setKey(key) { + _data.key = key; return this; }, - - addVersion({ version, migrate, create }: any) { - // input checking - if (version < 0) throw new Error("Cannot add version below 0 in store"); - if (version > 0 && !migrate) - throw new Error( - `Missing migration on version ${version} (needed for any version above 0)` - ); - - // update max version list - if (version > this._data.maxVersion) this._data.maxVersion = version; - // add to version list - this._data.versionList.push(version); - - // register version - this._data.versions[version.toString()] = { - version, // version number - update: migrate - ? (data: any) => { - // update function, and increment version - const newData = migrate(data); - newData["--version"] = version; // eslint-disable-line no-param-reassign - return newData; - } - : undefined, - init: create - ? () => { - // return an initial object - const data = create(); - data["--version"] = version; - return data; - } - : undefined, - }; + addVersion(ver) { + _data.versions.push(ver); return this; }, - - registerHelper({ name, helper, type }: any) { - // type - let helperType: string = type; - if (!helperType) helperType = "instance"; - - // input checking - if (!name || name.constructor !== String) { - throw new Error("helper name is not a string"); - } - if (!helper || helper.constructor !== Function) { - throw new Error("helper function is not a function"); - } - if (!["instance", "static"].includes(helperType)) { - throw new Error("helper type must be either 'instance' or 'static'"); - } - - // register helper - if (helperType === "instance") - this._data.instanceHelpers[name as string] = helper; - else if (helperType === "static") - this._data.staticHelpers[name as string] = helper; - - return this; - }, - build() { - // check if version list doesnt skip versions - const versionListSorted = this._data.versionList.sort( - (a: number, b: number) => a - b - ); - versionListSorted.forEach((v: any, i: number, arr: any[]) => { - if (i === 0) return; - if (v !== arr[i - 1] + 1) - throw new Error("Version list of store is not incremental"); - }); - - // version zero must exist - if (versionListSorted[0] !== 0) - throw new Error("Version 0 doesn't exist in version list of store"); - - // max version must have init function - if (!this._data.versions[this._data.maxVersion.toString()].init) - throw new Error( - `Missing create function on version ${this._data.maxVersion} (needed for latest version of store)` - ); - - // check storage string - if (!this._data.storageString) - throw new Error("storage key not set in store"); - - // build versioned store - return buildStoreObject(this._data); + assertStore(_data); + const storageObject = buildStorageObject(_data); + stores[_data.key ?? ""] = [storageObject, _data]; + return storageObject; }, }; } + +export function useStore( + store: StoreRet +): [T, (cb: (old: T) => T) => void] { + const [data, setData] = useState(store.get()); + useEffect(() => { + const { destroy } = store.onChange((newData) => { + setData(newData); + }); + return () => { + destroy(); + }; + }, [store]); + + function setNewData(cb: (old: T) => T) { + const newData = cb(data); + store.save(newData); + } + + return [data, setNewData]; +} diff --git a/src/utils/titleMatch.ts b/src/utils/titleMatch.ts new file mode 100644 index 00000000..dfdf3883 --- /dev/null +++ b/src/utils/titleMatch.ts @@ -0,0 +1,11 @@ +function normalizeTitle(title: string): string { + return title + .trim() + .toLowerCase() + .replace(/['":]/g, "") + .replace(/[^a-zA-Z0-9]+/g, "_"); +} + +export function compareTitle(a: string, b: string): boolean { + return normalizeTitle(a) === normalizeTitle(b); +} diff --git a/src/video/components/VideoPlayer.tsx b/src/video/components/VideoPlayer.tsx new file mode 100644 index 00000000..688f42e2 --- /dev/null +++ b/src/video/components/VideoPlayer.tsx @@ -0,0 +1,174 @@ +import { Transition } from "@/components/Transition"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { AirplayAction } from "@/video/components/actions/AirplayAction"; +import { BackdropAction } from "@/video/components/actions/BackdropAction"; +import { FullscreenAction } from "@/video/components/actions/FullscreenAction"; +import { HeaderAction } from "@/video/components/actions/HeaderAction"; +import { LoadingAction } from "@/video/components/actions/LoadingAction"; +import { MiddlePauseAction } from "@/video/components/actions/MiddlePauseAction"; +import { MobileCenterAction } from "@/video/components/actions/MobileCenterAction"; +import { PageTitleAction } from "@/video/components/actions/PageTitleAction"; +import { PauseAction } from "@/video/components/actions/PauseAction"; +import { ProgressAction } from "@/video/components/actions/ProgressAction"; +import { QualityDisplayAction } from "@/video/components/actions/QualityDisplayAction"; +import { SeriesSelectionAction } from "@/video/components/actions/SeriesSelectionAction"; +import { SourceSelectionAction } from "@/video/components/actions/SourceSelectionAction"; +import { CaptionsSelectionAction } from "@/video/components/actions/CaptionsSelectionAction"; +import { ShowTitleAction } from "@/video/components/actions/ShowTitleAction"; +import { KeyboardShortcutsAction } from "@/video/components/actions/KeyboardShortcutsAction"; +import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction"; +import { TimeAction } from "@/video/components/actions/TimeAction"; +import { VolumeAction } from "@/video/components/actions/VolumeAction"; +import { VideoPlayerError } from "@/video/components/parts/VideoPlayerError"; +import { + VideoPlayerBase, + VideoPlayerBaseProps, +} from "@/video/components/VideoPlayerBase"; +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { useControls } from "@/video/state/logic/controls"; +import { ReactNode, useCallback, useState } from "react"; +import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderAction"; +import { ChromecastAction } from "@/video/components/actions/ChromecastAction"; +import { CastingTextAction } from "@/video/components/actions/CastingTextAction"; + +type Props = VideoPlayerBaseProps; + +function CenterPosition(props: { children: ReactNode }) { + return ( +
+ {props.children} +
+ ); +} + +function LeftSideControls() { + const descriptor = useVideoPlayerDescriptor(); + const controls = useControls(descriptor); + + const handleMouseEnter = useCallback(() => { + controls.setLeftControlsHover(true); + }, [controls]); + const handleMouseLeave = useCallback(() => { + controls.setLeftControlsHover(false); + }, [controls]); + + return ( + <> +
+ + + + +
+ + + ); +} + +export function VideoPlayer(props: Props) { + const [show, setShow] = useState(false); + const { isMobile } = useIsMobile(); + + const onBackdropChange = useCallback( + (showing: boolean) => { + setShow(showing); + }, + [setShow] + ); + + return ( + + {({ isFullscreen }) => ( + <> + + + + + + + + + + + + + + {isMobile ? ( + + + + ) : ( + "" + )} + + + + +
+ {isMobile && } + +
+
+ {isMobile ? ( +
+
+
+ + + +
+ +
+ ) : ( + <> + +
+ + + +
+ + + + + + )} +
+ + {show ? : null} + + {props.children} + + + )} + + ); +} diff --git a/src/video/components/VideoPlayerBase.tsx b/src/video/components/VideoPlayerBase.tsx new file mode 100644 index 00000000..954bf0d1 --- /dev/null +++ b/src/video/components/VideoPlayerBase.tsx @@ -0,0 +1,60 @@ +import { CastingInternal } from "@/video/components/internal/CastingInternal"; +import { WrapperRegisterInternal } from "@/video/components/internal/WrapperRegisterInternal"; +import { VideoErrorBoundary } from "@/video/components/parts/VideoErrorBoundary"; +import { useInterface } from "@/video/state/logic/interface"; +import { useMeta } from "@/video/state/logic/meta"; +import { useRef } from "react"; +import { + useVideoPlayerDescriptor, + VideoPlayerContextProvider, +} from "../state/hooks"; +import { VideoElementInternal } from "./internal/VideoElementInternal"; + +export interface VideoPlayerBaseProps { + children?: + | React.ReactNode + | ((data: { isFullscreen: boolean }) => React.ReactNode); + autoPlay?: boolean; + includeSafeArea?: boolean; + onGoBack?: () => void; +} + +function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) { + const ref = useRef(null); + const descriptor = useVideoPlayerDescriptor(); + const videoInterface = useInterface(descriptor); + const media = useMeta(descriptor); + + const children = + typeof props.children === "function" + ? props.children({ isFullscreen: videoInterface.isFullscreen }) + : props.children; + + // TODO move error boundary to only decorated, shouldn't have styling + return ( + +
+ + + +
{children}
+
+
+ ); +} + +export function VideoPlayerBase(props: VideoPlayerBaseProps) { + return ( + + + + ); +} diff --git a/src/video/components/actions/AirplayAction.tsx b/src/video/components/actions/AirplayAction.tsx new file mode 100644 index 00000000..e963166e --- /dev/null +++ b/src/video/components/actions/AirplayAction.tsx @@ -0,0 +1,30 @@ +import { Icons } from "@/components/Icon"; +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { useControls } from "@/video/state/logic/controls"; +import { useMisc } from "@/video/state/logic/misc"; +import { useCallback } from "react"; +import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; + +interface Props { + className?: string; +} + +export function AirplayAction(props: Props) { + const descriptor = useVideoPlayerDescriptor(); + const controls = useControls(descriptor); + const misc = useMisc(descriptor); + + const handleClick = useCallback(() => { + controls.startAirplay(); + }, [controls]); + + if (!misc.canAirplay) return null; + + return ( + + ); +} diff --git a/src/video/components/actions/BackdropAction.tsx b/src/video/components/actions/BackdropAction.tsx new file mode 100644 index 00000000..dcceff60 --- /dev/null +++ b/src/video/components/actions/BackdropAction.tsx @@ -0,0 +1,113 @@ +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { useControls } from "@/video/state/logic/controls"; +import { useInterface } from "@/video/state/logic/interface"; +import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; +import React, { useCallback, useEffect, useRef, useState } from "react"; + +interface BackdropActionProps { + children?: React.ReactNode; + onBackdropChange?: (showing: boolean) => void; +} + +export function BackdropAction(props: BackdropActionProps) { + const descriptor = useVideoPlayerDescriptor(); + const controls = useControls(descriptor); + const mediaPlaying = useMediaPlaying(descriptor); + const videoInterface = useInterface(descriptor); + + const [moved, setMoved] = useState(false); + const timeout = useRef | null>(null); + const clickareaRef = useRef(null); + + const handleMouseMove = useCallback(() => { + if (!moved) setMoved(true); + if (timeout.current) clearTimeout(timeout.current); + timeout.current = setTimeout(() => { + if (moved) setMoved(false); + timeout.current = null; + }, 3000); + }, [setMoved, moved]); + + const handleMouseLeave = useCallback(() => { + setMoved(false); + }, [setMoved]); + + const [lastTouchEnd, setLastTouchEnd] = useState(0); + + const handleClick = useCallback( + ( + e: React.MouseEvent | React.TouchEvent + ) => { + if (!clickareaRef.current || clickareaRef.current !== e.target) return; + + if (videoInterface.popout !== null) return; + + if ((e as React.TouchEvent).type === "touchend") { + setLastTouchEnd(Date.now()); + return; + } + + setTimeout(() => { + if (Date.now() - lastTouchEnd < 200) { + setMoved(!moved); + return; + } + + if (mediaPlaying.isPlaying) controls.pause(); + else controls.play(); + }, 20); + }, + [controls, mediaPlaying, videoInterface, lastTouchEnd, moved] + ); + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + if (!clickareaRef.current || clickareaRef.current !== e.target) return; + + if (!videoInterface.isFullscreen) controls.enterFullscreen(); + else controls.exitFullscreen(); + }, + [controls, videoInterface] + ); + + const lastBackdropValue = useRef(null); + useEffect(() => { + const currentValue = + moved || mediaPlaying.isPaused || !!videoInterface.popout; + if (currentValue !== lastBackdropValue.current) { + lastBackdropValue.current = currentValue; + props.onBackdropChange?.(currentValue); + } + }, [moved, mediaPlaying, props, videoInterface]); + const showUI = moved || mediaPlaying.isPaused || !!videoInterface.popout; + + return ( +
+
+
+
+
+ {props.children} +
+
+ ); +} diff --git a/src/video/components/actions/CaptionsSelectionAction.tsx b/src/video/components/actions/CaptionsSelectionAction.tsx new file mode 100644 index 00000000..d6cc4328 --- /dev/null +++ b/src/video/components/actions/CaptionsSelectionAction.tsx @@ -0,0 +1,34 @@ +import { Icons } from "@/components/Icon"; +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton"; +import { useControls } from "@/video/state/logic/controls"; +import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { useTranslation } from "react-i18next"; + +interface Props { + className?: string; +} + +export function CaptionsSelectionAction(props: Props) { + const { t } = useTranslation(); + const descriptor = useVideoPlayerDescriptor(); + const controls = useControls(descriptor); + const { isMobile } = useIsMobile(); + + return ( +
+
+ + controls.openPopout("captions")} + icon={Icons.CAPTIONS} + /> + +
+
+ ); +} diff --git a/src/video/components/actions/CastingTextAction.tsx b/src/video/components/actions/CastingTextAction.tsx new file mode 100644 index 00000000..d95a0351 --- /dev/null +++ b/src/video/components/actions/CastingTextAction.tsx @@ -0,0 +1,22 @@ +import { Icon, Icons } from "@/components/Icon"; +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { useMisc } from "@/video/state/logic/misc"; +import { useTranslation } from "react-i18next"; + +export function CastingTextAction() { + const { t } = useTranslation(); + + const descriptor = useVideoPlayerDescriptor(); + const misc = useMisc(descriptor); + + if (!misc.isCasting) return null; + + return ( +
+
+ +
+

{t("casting.casting")}

+
+ ); +} diff --git a/src/video/components/actions/ChromecastAction.tsx b/src/video/components/actions/ChromecastAction.tsx new file mode 100644 index 00000000..7334af67 --- /dev/null +++ b/src/video/components/actions/ChromecastAction.tsx @@ -0,0 +1,60 @@ +import { Icons } from "@/components/Icon"; +import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton"; +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { useMisc } from "@/video/state/logic/misc"; +import { useCallback, useEffect, useRef, useState } from "react"; + +interface Props { + className?: string; +} + +export function ChromecastAction(props: Props) { + const [hidden, setHidden] = useState(false); + const descriptor = useVideoPlayerDescriptor(); + const misc = useMisc(descriptor); + const isCasting = misc.isCasting; + const ref = useRef(null); + + const setButtonVisibility = useCallback( + (tag: HTMLElement) => { + const isVisible = (tag.getAttribute("style") ?? "").includes("inline"); + setHidden(!isVisible); + }, + [setHidden] + ); + + useEffect(() => { + const tag = ref.current?.querySelector("google-cast-launcher"); + if (!tag) return; + + const observer = new MutationObserver(() => { + setButtonVisibility(tag); + }); + + observer.observe(tag, { attributes: true, attributeFilter: ["style"] }); + setButtonVisibility(tag); + + return () => { + observer.disconnect(); + }; + }, [setButtonVisibility]); + + return ( +