mirror of
https://github.com/movie-web/movie-web.git
synced 2025-09-13 15:33:25 +00:00
Compare commits
108 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
1c77807987 | ||
|
9bba47575a | ||
|
dace2338be | ||
|
30d8e11992 | ||
|
9c9ce92681 | ||
|
30cc5aa78b | ||
|
ac28f32ef4 | ||
|
fca9fea265 | ||
|
c2bd7714ed | ||
|
48214af202 | ||
|
72ad53ee56 | ||
|
02d94ba411 | ||
|
84913aa63d | ||
|
9d7ddc03a5 | ||
|
5327cbffaa | ||
|
695ccef2b5 | ||
|
addd8ca031 | ||
|
dd662efd72 | ||
|
900c70e36a | ||
|
68a1470447 | ||
|
b42d36c5ac | ||
|
6b9774a210 | ||
|
a5cd05b144 | ||
|
bdb4b3507a | ||
|
ca6383900a | ||
|
5e97a195d9 | ||
|
25e32a14b7 | ||
|
139a760be0 | ||
|
a3e244285c | ||
|
935cb2427b | ||
|
404cd897f3 | ||
|
fac0a878f3 | ||
|
596e680a18 | ||
|
c6bf568514 | ||
|
4a38c77e2d | ||
|
163ca0df29 | ||
|
3fad6edaad | ||
|
f2f7925cbb | ||
|
b9026c50f5 | ||
|
a1f3986e64 | ||
|
224cdb6710 | ||
|
f76db3e4b7 | ||
|
9abb009725 | ||
|
0ca4b3cf49 | ||
|
9418a7c45d | ||
|
d34d2c8ce0 | ||
|
281785a0ef | ||
|
28c008a77f | ||
|
717ebbaeae | ||
|
f715f70f9e | ||
|
24aeb68f55 | ||
|
8ed0d3740f | ||
|
444c751b78 | ||
|
63b9adf7d8 | ||
|
3a1c3ad260 | ||
|
e68fe0e115 | ||
|
d51246120d | ||
|
23b439ff79 | ||
|
ac350f276c | ||
|
854e6bede4 | ||
|
25670814e4 | ||
|
7c2ad68c2a | ||
|
e82173efbe | ||
|
485698a43c | ||
|
444156236c | ||
|
4f9ef382dc | ||
|
cedc987509 | ||
|
a99437b4cc | ||
|
7f28e7be3d | ||
|
efc2c8a67d | ||
|
02cd565f84 | ||
|
0625719a4d | ||
|
16298431f4 | ||
|
7d6656aef2 | ||
|
564bcccff8 | ||
|
177df9a6f2 | ||
|
e44b36c83e | ||
|
3696a05e1e | ||
|
abeb68d4a3 | ||
|
d10d4faf56 | ||
|
f5e5b48616 | ||
|
9ff49e42a3 | ||
|
d6a46e1cdc | ||
|
d10cbd5e9b | ||
|
1853c8eac7 | ||
|
6908588c00 | ||
|
48ab781bb9 | ||
|
fbd683e0b5 | ||
|
3b3457532a | ||
|
ef7b9ff475 | ||
|
c5aacd72ce | ||
|
620e63f17c | ||
|
4d8257a05f | ||
|
0f9d7faaf2 | ||
|
afa89c02a0 | ||
|
2bef75dd4a | ||
|
35adaf3872 | ||
|
a2e5e08b20 | ||
|
39ede1b042 | ||
|
32288357c4 | ||
|
35ecaece5b | ||
|
25ccd941ca | ||
|
bfbb4c6b11 | ||
|
f13ed7cae1 | ||
|
44f59e9708 | ||
|
92fa9716e5 | ||
|
e289f9a228 | ||
|
68868b37a8 |
@@ -43,6 +43,7 @@ module.exports = {
|
||||
"no-shadow": "off",
|
||||
"@typescript-eslint/no-shadow": ["error"],
|
||||
"no-restricted-syntax": "off",
|
||||
"import/no-unresolved": ["error", { ignore: ["^virtual:"] }],
|
||||
"react/jsx-props-no-spreading": "off",
|
||||
"consistent-return": "off",
|
||||
"no-continue": "off",
|
||||
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
3
.github/workflows/deploying.yml
vendored
3
.github/workflows/deploying.yml
vendored
@@ -18,12 +18,13 @@ jobs:
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
run: yarn build
|
||||
|
||||
- name: Upload production-ready build files
|
||||
uses: actions/upload-artifact@v3
|
||||
|
48
.github/workflows/linting_annotate.yml
vendored
Normal file
48
.github/workflows/linting_annotate.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Annotate linting
|
||||
|
||||
permissions:
|
||||
actions: read # download artifact
|
||||
checks: write # annotate
|
||||
|
||||
# this is done as a seperate workflow so
|
||||
# the annotater has access to write to checks (to annotate)
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Linting and Testing"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
annotate:
|
||||
name: Annotate linting
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Download linting report
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{github.event.workflow_run.id }},
|
||||
});
|
||||
const matchArtifact = artifacts.data.artifacts.filter((artifact) => {
|
||||
return artifact.name == "eslint_report.json"
|
||||
})[0];
|
||||
const download = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: matchArtifact.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
const fs = require('fs');
|
||||
fs.writeFileSync('${{github.workspace}}/eslint_report.zip', Buffer.from(download.data));
|
||||
|
||||
- run: unzip eslint_report.zip
|
||||
|
||||
- name: Annotate linting
|
||||
uses: ataylorme/eslint-annotate-action@v2
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
report-json: "eslint_report.json"
|
30
.github/workflows/linting_testing.yml
vendored
30
.github/workflows/linting_testing.yml
vendored
@@ -5,8 +5,7 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- dev
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
linting:
|
||||
@@ -21,6 +20,7 @@ jobs:
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
@@ -30,11 +30,27 @@ jobs:
|
||||
# continue on error, so it still reports it in the next step
|
||||
continue-on-error: true
|
||||
|
||||
- name: Annotate Code Linting Results
|
||||
uses: ataylorme/eslint-annotate-action@v2
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
report-json: "eslint_report.json"
|
||||
name: eslint_report.json
|
||||
path: eslint_report.json
|
||||
|
||||
building:
|
||||
name: Build project
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
|
||||
- name: Build Project
|
||||
run: npm run build
|
||||
run: yarn build
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ node_modules
|
||||
|
||||
# production
|
||||
/dist
|
||||
dev-dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
||||
"eslint.format.enable": true
|
||||
"eslint.format.enable": true,
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
|
@@ -40,7 +40,7 @@ To run this project locally for contributing or testing, run the following comma
|
||||
git clone https://github.com/movie-web/movie-web
|
||||
cd movie-web
|
||||
yarn install
|
||||
yarn start
|
||||
yarn dev
|
||||
```
|
||||
|
||||
To build production files, simply run `yarn build`.
|
||||
|
29
index.html
29
index.html
@@ -1,36 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-44YVXRL61C"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
|
||||
gtag("config", "G-44YVXRL61C");
|
||||
</script>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Because watching movies legally is boring"
|
||||
content="The place for your favourite movies & shows"
|
||||
/>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#E880C5" />
|
||||
<meta name="msapplication-TileColor" content="#E880C5" />
|
||||
<meta name="theme-color" content="#E880C5" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#120f1d" />
|
||||
<meta name="msapplication-TileColor" content="#120f1d" />
|
||||
<meta name="theme-color" content="#120f1d" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
@@ -40,7 +24,10 @@
|
||||
/>
|
||||
|
||||
<script src="config.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/movie-web/6C6F6C7A@3744edbc5f64a77985b6421ea5040e688663634b/out.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/movie-web/6C6F6C7A@8b821f445b83d51ef1b8f42c99b7346f6b47dce5/out.js"></script>
|
||||
|
||||
<!-- prevent darkreader extension from messing with our already dark site -->
|
||||
<meta name="darkreader-lock" />
|
||||
|
||||
<title>movie-web</title>
|
||||
</head>
|
||||
|
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "movie-web",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.5",
|
||||
"private": true,
|
||||
"homepage": "https://movie.squeezebox.dev",
|
||||
"dependencies": {
|
||||
@@ -20,17 +20,20 @@
|
||||
"pako": "^2.1.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-ga4": "^2.0.0",
|
||||
"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",
|
||||
"react-use": "^17.4.0",
|
||||
"srt-webvtt": "^2.0.0",
|
||||
"unpacker": "^1.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint --ext .tsx,.ts src",
|
||||
"lint:fix": "eslint --fix --ext .tsx,.ts src",
|
||||
@@ -75,6 +78,7 @@
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "7.29.4",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
"jsdom": "^21.1.0",
|
||||
"postcss": "^8.4.20",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.7",
|
||||
@@ -83,6 +87,9 @@
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^4.0.1",
|
||||
"vite-plugin-checker": "^0.5.6",
|
||||
"vite-plugin-package-version": "^1.0.2"
|
||||
"vite-plugin-package-version": "^1.0.2",
|
||||
"vite-plugin-pwa": "^0.14.4",
|
||||
"vitest": "^0.28.5",
|
||||
"workbox-window": "^6.5.4"
|
||||
}
|
||||
}
|
||||
|
5
public/_headers
Normal file
5
public/_headers
Normal file
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-XSS-Protection: 1; mode=block
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: origin-when-cross-origin
|
@@ -3,7 +3,7 @@
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#da532c</TileColor>
|
||||
<TileColor>#120f1d</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
@@ -1,7 +1,6 @@
|
||||
window.__CONFIG__ = {
|
||||
// url must NOT end with a slash
|
||||
VITE_CORS_PROXY_URL: "",
|
||||
|
||||
VITE_TMDB_API_KEY: "b030404650f279792a8d3287232358e3",
|
||||
VITE_OMDB_API_KEY: "aa0937c0"
|
||||
VITE_OMDB_API_KEY: "aa0937c0",
|
||||
};
|
||||
|
1
public/ping.txt
Normal file
1
public/ping.txt
Normal file
@@ -0,0 +1 @@
|
||||
pong
|
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "movie-web",
|
||||
"short_name": "movie-web",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#E880C5",
|
||||
"background_color": "#16171D",
|
||||
"display": "standalone",
|
||||
"start_url": "/"
|
||||
}
|
51
src/__tests__/providers/providers.test.ts
Normal file
51
src/__tests__/providers/providers.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it } from "vitest";
|
||||
import "@/backend";
|
||||
import { getProviders } from "@/backend/helpers/register";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { runProvider } from "@/backend/helpers/run";
|
||||
import { testData } from "@/__tests__/providers/testdata";
|
||||
|
||||
describe("providers", () => {
|
||||
const providers = getProviders();
|
||||
|
||||
it("have at least one provider", ({ expect }) => {
|
||||
expect(providers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const provider of providers) {
|
||||
describe(provider.displayName, () => {
|
||||
it("must have at least one type", async ({ expect }) => {
|
||||
expect(provider.type.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
if (provider.type.includes(MWMediaType.MOVIE)) {
|
||||
it("must work with movies", async ({ expect }) => {
|
||||
const movie = testData.find((v) => v.meta.type === MWMediaType.MOVIE);
|
||||
if (!movie) throw new Error("no movie to test with");
|
||||
const results = await runProvider(provider, {
|
||||
media: movie,
|
||||
progress() {},
|
||||
type: movie.meta.type as any,
|
||||
});
|
||||
expect(results).toBeTruthy();
|
||||
});
|
||||
}
|
||||
|
||||
if (provider.type.includes(MWMediaType.SERIES)) {
|
||||
it("must work with series", async ({ expect }) => {
|
||||
const show = testData.find((v) => v.meta.type === MWMediaType.SERIES);
|
||||
if (show?.meta.type !== MWMediaType.SERIES)
|
||||
throw new Error("no show to test with");
|
||||
const results = await runProvider(provider, {
|
||||
media: show,
|
||||
progress() {},
|
||||
type: show.meta.type as MWMediaType.SERIES,
|
||||
episode: show.meta.seasonData.episodes[0].id,
|
||||
season: show.meta.seasons[0].id,
|
||||
});
|
||||
expect(results).toBeTruthy();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
45
src/__tests__/providers/testdata.ts
Normal file
45
src/__tests__/providers/testdata.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
|
||||
export const testData: DetailedMeta[] = [
|
||||
{
|
||||
imdbId: "tt10954562",
|
||||
tmdbId: "572716",
|
||||
meta: {
|
||||
id: "439596",
|
||||
title: "Hamilton",
|
||||
type: MWMediaType.MOVIE,
|
||||
year: "2020",
|
||||
seasons: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
imdbId: "tt11126994",
|
||||
tmdbId: "94605",
|
||||
meta: {
|
||||
id: "222333",
|
||||
title: "Arcane",
|
||||
type: MWMediaType.SERIES,
|
||||
year: "2021",
|
||||
seasons: [
|
||||
{
|
||||
id: "230301",
|
||||
number: 1,
|
||||
title: "Season 1",
|
||||
},
|
||||
],
|
||||
seasonData: {
|
||||
id: "230301",
|
||||
number: 1,
|
||||
title: "Season 1",
|
||||
episodes: [
|
||||
{
|
||||
id: "4243445",
|
||||
number: 1,
|
||||
title: "Welcome to the Playground",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
@@ -2,6 +2,7 @@ import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
|
||||
import toWebVTT from "srt-webvtt";
|
||||
|
||||
export const CUSTOM_CAPTION_ID = "customCaption";
|
||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
if (caption.type === MWCaptionType.SRT) {
|
||||
let captionBlob: Blob;
|
||||
@@ -32,3 +33,18 @@ export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
|
||||
throw new Error("invalid type");
|
||||
}
|
||||
|
||||
export async function convertCustomCaptionFileToWebVTT(file: File) {
|
||||
const header = await file.slice(0, 6).text();
|
||||
const isWebVTT = header === "WEBVTT";
|
||||
if (!isWebVTT) {
|
||||
return toWebVTT(file);
|
||||
}
|
||||
return URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
export function revokeCaptionBlob(url: string | undefined) {
|
||||
if (url && url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,15 @@
|
||||
import { conf } from "@/setup/config";
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
let proxyUrlIndex = Math.floor(Math.random() * conf().PROXY_URLS.length);
|
||||
|
||||
// round robins all proxy urls
|
||||
function getProxyUrl(): string {
|
||||
const url = conf().PROXY_URLS[proxyUrlIndex];
|
||||
proxyUrlIndex = (proxyUrlIndex + 1) % conf().PROXY_URLS.length;
|
||||
return url;
|
||||
}
|
||||
|
||||
type P<T> = Parameters<typeof ofetch<T>>;
|
||||
type R<T> = ReturnType<typeof ofetch<T>>;
|
||||
|
||||
@@ -41,7 +50,7 @@ export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
|
||||
parsedUrl.searchParams.set(k, v);
|
||||
});
|
||||
|
||||
return baseFetch<T>(conf().BASE_PROXY_URL, {
|
||||
return baseFetch<T>(getProxyUrl(), {
|
||||
...ops,
|
||||
baseURL: undefined,
|
||||
params: {
|
||||
|
@@ -54,12 +54,17 @@ export async function getMetaFromId(
|
||||
throw err;
|
||||
}
|
||||
|
||||
const imdbId = data.external_ids.find(
|
||||
let imdbId = data.external_ids.find(
|
||||
(v) => v.provider === "imdb_latest"
|
||||
)?.external_id;
|
||||
const tmdbId = data.external_ids.find(
|
||||
if (!imdbId)
|
||||
imdbId = data.external_ids.find((v) => v.provider === "imdb")?.external_id;
|
||||
|
||||
let tmdbId = data.external_ids.find(
|
||||
(v) => v.provider === "tmdb_latest"
|
||||
)?.external_id;
|
||||
if (!tmdbId)
|
||||
tmdbId = data.external_ids.find((v) => v.provider === "tmdb")?.external_id;
|
||||
|
||||
if (!imdbId || !tmdbId) throw new Error("not enough info");
|
||||
|
||||
|
@@ -4,7 +4,10 @@ import { registerProvider } from "../helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
|
||||
const flixHqBase = "https://api.consumet.org/movies/flixhq";
|
||||
// const flixHqBase = "https://api.consumet.org/movies/flixhq";
|
||||
// *** TEMPORARY FIX - use other instance
|
||||
// SEE ISSUE: https://github.com/consumet/api.consumet.org/issues/326
|
||||
const flixHqBase = "https://c.delusionz.xyz/movies/flixhq";
|
||||
|
||||
registerProvider({
|
||||
id: "flixhq",
|
||||
|
@@ -35,6 +35,7 @@ const format = {
|
||||
registerProvider({
|
||||
id: "gdriveplayer",
|
||||
displayName: "gdriveplayer",
|
||||
disabled: true,
|
||||
rank: 69,
|
||||
type: [MWMediaType.MOVIE],
|
||||
|
||||
|
@@ -1,6 +1,10 @@
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import {
|
||||
MWCaptionType,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
|
||||
const netfilmBase = "https://net-film.vercel.app";
|
||||
@@ -16,7 +20,7 @@ type QualityInMap = keyof typeof qualityMap;
|
||||
registerProvider({
|
||||
id: "netfilm",
|
||||
displayName: "NetFilm",
|
||||
rank: 150,
|
||||
rank: 15,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
@@ -47,20 +51,29 @@ registerProvider({
|
||||
}
|
||||
);
|
||||
|
||||
const { qualities } = watchInfo.data;
|
||||
const data = watchInfo.data;
|
||||
|
||||
// get best quality source
|
||||
const source = qualities.reduce((p: any, c: any) =>
|
||||
const source = data.qualities.reduce((p: any, c: any) =>
|
||||
c.quality > p.quality ? c : p
|
||||
);
|
||||
|
||||
const mappedCaptions = data.subtitles.map((sub: Record<string, any>) => ({
|
||||
needsProxy: false,
|
||||
url: sub.url.replace("https://convert-srt-to-vtt.vercel.app/?url=", ""),
|
||||
type: MWCaptionType.SRT,
|
||||
langIso: sub.language,
|
||||
}));
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: source.url,
|
||||
streamUrl: source.url
|
||||
.replace("akm-cdn", "aws-cdn")
|
||||
.replace("gg-cdn", "aws-cdn"),
|
||||
quality: qualityMap[source.quality as QualityInMap],
|
||||
type: MWStreamType.HLS,
|
||||
captions: [],
|
||||
captions: mappedCaptions,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -108,20 +121,29 @@ registerProvider({
|
||||
}
|
||||
);
|
||||
|
||||
const { qualities } = episodeStream.data;
|
||||
const data = episodeStream.data;
|
||||
|
||||
// get best quality source
|
||||
const source = qualities.reduce((p: any, c: any) =>
|
||||
const source = data.qualities.reduce((p: any, c: any) =>
|
||||
c.quality > p.quality ? c : p
|
||||
);
|
||||
|
||||
const mappedCaptions = data.subtitles.map((sub: Record<string, any>) => ({
|
||||
needsProxy: false,
|
||||
url: sub.url.replace("https://convert-srt-to-vtt.vercel.app/?url=", ""),
|
||||
type: MWCaptionType.SRT,
|
||||
langIso: sub.language,
|
||||
}));
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: source.url,
|
||||
streamUrl: source.url
|
||||
.replace("akm-cdn", "aws-cdn")
|
||||
.replace("gg-cdn", "aws-cdn"),
|
||||
quality: qualityMap[source.quality as QualityInMap],
|
||||
type: MWStreamType.HLS,
|
||||
captions: [],
|
||||
captions: mappedCaptions,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
28
src/components/Banner.tsx
Normal file
28
src/components/Banner.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useBanner } from "@/hooks/useBanner";
|
||||
|
||||
export function Banner(props: { children: React.ReactNode; type: "error" }) {
|
||||
const [ref] = useBanner<HTMLDivElement>("internet");
|
||||
const styles = {
|
||||
error: "bg-[#C93957] text-white",
|
||||
};
|
||||
const icons = {
|
||||
error: Icons.CIRCLE_EXCLAMATION,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div
|
||||
className={[
|
||||
styles[props.type],
|
||||
"flex items-center justify-center p-1",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Icon icon={icons[props.type]} />
|
||||
<div>{props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -34,6 +34,9 @@ export enum Icons {
|
||||
CAPTIONS = "captions",
|
||||
LINK = "link",
|
||||
CASTING = "casting",
|
||||
CIRCLE_EXCLAMATION = "circle_exclamation",
|
||||
DOWNLOAD = "download",
|
||||
PICTURE_IN_PICTURE = "pictureInPicture",
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
@@ -74,7 +77,10 @@ const iconList: Record<Icons, string> = {
|
||||
file: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>`,
|
||||
captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM200 208c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48s21.5-48 48-48zm144 48c0-26.5 21.5-48 48-48c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48z"/></svg>`,
|
||||
link: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
|
||||
circle_exclamation: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24V264c0 13.3-10.7 24-24 24s-24-10.7-24-24V152c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>`,
|
||||
casting: "",
|
||||
download: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
|
||||
pictureInPicture: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 1.98 2 1.98h18c1.1 0 2-.88 2-1.98V5c0-1.1-.9-2-2-2zm0 16.01H3V4.98h18v14.03z"/></svg>`,
|
||||
};
|
||||
|
||||
function ChromeCastButton() {
|
||||
|
@@ -1,7 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
export function BrandPill(props: { clickable?: boolean }) {
|
||||
export function BrandPill(props: {
|
||||
clickable?: boolean;
|
||||
hideTextOnMobile?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -13,7 +16,14 @@ export function BrandPill(props: { clickable?: boolean }) {
|
||||
}`}
|
||||
>
|
||||
<Icon className="text-xl" icon={Icons.MOVIE_WEB} />
|
||||
<span className="font-semibold text-white">{t("global.name")}</span>
|
||||
<span
|
||||
className={[
|
||||
"font-semibold text-white",
|
||||
props.hideTextOnMobile ? "hidden sm:block" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{t("global.name")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { conf } from "@/setup/config";
|
||||
import { useBannerSize } from "@/hooks/useBanner";
|
||||
import { BrandPill } from "./BrandPill";
|
||||
|
||||
export interface NavigationProps {
|
||||
@@ -11,8 +12,16 @@ export interface NavigationProps {
|
||||
}
|
||||
|
||||
export function Navigation(props: NavigationProps) {
|
||||
const bannerHeight = useBannerSize();
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 z-10 flex min-h-[88px] items-center justify-between py-5 px-7">
|
||||
<div
|
||||
className="fixed left-0 right-0 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent"
|
||||
style={{
|
||||
top: `${bannerHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div className="fixed left-0 right-0 flex items-center justify-between py-5 px-7">
|
||||
<div
|
||||
className={`${
|
||||
props.bg ? "opacity-100" : "opacity-0"
|
||||
@@ -51,5 +60,6 @@ export function Navigation(props: NavigationProps) {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -10,8 +10,8 @@ interface SectionHeadingProps {
|
||||
|
||||
export function SectionHeading(props: SectionHeadingProps) {
|
||||
return (
|
||||
<div className={`mt-12 ${props.className}`}>
|
||||
<div className="mb-4 flex items-end">
|
||||
<div className={props.className}>
|
||||
<div className="mb-5 flex items-center">
|
||||
<p className="flex flex-1 items-center font-bold uppercase text-denim-700">
|
||||
{props.icon ? (
|
||||
<span className="mr-2 text-xl">
|
||||
|
@@ -45,14 +45,27 @@ function MediaCardContent({
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100 group-hover:rounded-lg"
|
||||
className={[
|
||||
"relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100",
|
||||
closable ? "" : "group-hover:rounded-lg",
|
||||
].join(" ")}
|
||||
style={{
|
||||
backgroundImage: media.poster ? `url(${media.poster})` : undefined,
|
||||
}}
|
||||
>
|
||||
{series ? (
|
||||
<div className="absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors group-hover:bg-denim-500">
|
||||
<p className="text-center text-xs font-bold text-slate-400 transition-colors group-hover:text-white">
|
||||
<div
|
||||
className={[
|
||||
"absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors",
|
||||
closable ? "" : "group-hover:bg-denim-500",
|
||||
].join(" ")}
|
||||
>
|
||||
<p
|
||||
className={[
|
||||
"text-center text-xs font-bold text-slate-400 transition-colors",
|
||||
closable ? "" : "group-hover:text-white",
|
||||
].join(" ")}
|
||||
>
|
||||
{t("seasons.seasonAndEpisode", {
|
||||
season: series.season,
|
||||
episode: series.episode,
|
||||
@@ -125,5 +138,9 @@ export function MediaCard(props: MediaCardProps) {
|
||||
)}`;
|
||||
|
||||
if (!props.linkable) return <span>{content}</span>;
|
||||
return <Link to={link}>{content}</Link>;
|
||||
return (
|
||||
<Link to={link} className={props.closable ? "hover:cursor-default" : ""}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
61
src/hooks/useBanner.tsx
Normal file
61
src/hooks/useBanner.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useState,
|
||||
useMemo,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useContext,
|
||||
} from "react";
|
||||
import { useMeasure } from "react-use";
|
||||
|
||||
interface BannerInstance {
|
||||
id: string;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const BannerContext = createContext<
|
||||
[BannerInstance[], Dispatch<SetStateAction<BannerInstance[]>>]
|
||||
>(null as any);
|
||||
|
||||
export function BannerContextProvider(props: { children: ReactNode }) {
|
||||
const [state, setState] = useState<BannerInstance[]>([]);
|
||||
const memod = useMemo<
|
||||
[BannerInstance[], Dispatch<SetStateAction<BannerInstance[]>>]
|
||||
>(() => [state, setState], [state]);
|
||||
|
||||
return (
|
||||
<BannerContext.Provider value={memod}>
|
||||
{props.children}
|
||||
</BannerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBanner<T extends Element>(id: string) {
|
||||
const [ref, { height }] = useMeasure<T>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_, set] = useContext(BannerContext);
|
||||
|
||||
useEffect(() => {
|
||||
set((v) => [...v, { id, height: 0 }]);
|
||||
set((value) => {
|
||||
const v = value.find((item) => item.id === id);
|
||||
if (v) {
|
||||
v.height = height;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
return () => {
|
||||
set((v) => v.filter((item) => item.id !== id));
|
||||
};
|
||||
}, [height, id, set]);
|
||||
|
||||
return [ref];
|
||||
}
|
||||
|
||||
export function useBannerSize() {
|
||||
const [val] = useContext(BannerContext);
|
||||
|
||||
return val.reduce((a, v) => a + v.height, 0);
|
||||
}
|
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useIsMobile() {
|
||||
export function useIsMobile(horizontal?: boolean) {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const isMobileCurrent = useRef<boolean | null>(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onResize() {
|
||||
const value = window.innerWidth < 1024;
|
||||
const value = horizontal
|
||||
? window.innerHeight < 600
|
||||
: window.innerWidth < 1024;
|
||||
const isChanged = isMobileCurrent.current !== value;
|
||||
if (!isChanged) return;
|
||||
|
||||
@@ -20,7 +22,7 @@ export function useIsMobile() {
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, []);
|
||||
}, [horizontal]);
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
|
41
src/hooks/usePing.ts
Normal file
41
src/hooks/usePing.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useIsOnline() {
|
||||
const [online, setOnline] = useState<boolean | null>(true);
|
||||
const ref = useRef<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
let counter = 0;
|
||||
|
||||
let abort: null | AbortController = null;
|
||||
const interval = setInterval(() => {
|
||||
// if online try once every 10 iterations intead of every iteration
|
||||
counter += 1;
|
||||
if (ref.current) {
|
||||
if (counter < 10) return;
|
||||
}
|
||||
counter = 0;
|
||||
|
||||
if (abort) abort.abort();
|
||||
abort = new AbortController();
|
||||
const signal = abort.signal;
|
||||
fetch("/ping.txt", { signal })
|
||||
.then(() => {
|
||||
setOnline(true);
|
||||
ref.current = true;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === "AbortError") return;
|
||||
setOnline(false);
|
||||
ref.current = false;
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (abort) abort.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return online;
|
||||
}
|
@@ -3,8 +3,10 @@ import ReactDOM from "react-dom";
|
||||
import { BrowserRouter, HashRouter } from "react-router-dom";
|
||||
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
|
||||
import { conf } from "@/setup/config";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
import App from "@/setup/App";
|
||||
import "@/setup/ga";
|
||||
import "@/setup/i18n";
|
||||
import "@/setup/index.css";
|
||||
import "@/backend";
|
||||
@@ -15,9 +17,14 @@ import { initializeStores } from "./utils/storage";
|
||||
const key =
|
||||
(window as any)?.__CONFIG__?.VITE_KEY ?? import.meta.env.VITE_KEY ?? null;
|
||||
if (key) {
|
||||
(window as any).initMW(conf().BASE_PROXY_URL, key);
|
||||
(window as any).initMW(conf().PROXY_URLS, key);
|
||||
}
|
||||
initializeChromecast();
|
||||
registerSW({
|
||||
onNeedRefresh() {
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
|
||||
const LazyLoadedApp = React.lazy(async () => {
|
||||
await initializeStores();
|
||||
|
@@ -7,25 +7,52 @@ import { MediaView } from "@/views/media/MediaView";
|
||||
import { SearchView } from "@/views/search/SearchView";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { V2MigrationView } from "@/views/other/v2Migration";
|
||||
import { DeveloperView } from "@/views/developer/DeveloperView";
|
||||
import { VideoTesterView } from "@/views/developer/VideoTesterView";
|
||||
import { ProviderTesterView } from "@/views/developer/ProviderTesterView";
|
||||
import { EmbedTesterView } from "@/views/developer/EmbedTesterView";
|
||||
import { BannerContextProvider } from "@/hooks/useBanner";
|
||||
import { Layout } from "@/setup/Layout";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<WatchedContextProvider>
|
||||
<BookmarkContextProvider>
|
||||
<BannerContextProvider>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{/* functional routes */}
|
||||
<Route exact path="/v2-migration" component={V2MigrationView} />
|
||||
<Route exact path="/">
|
||||
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
|
||||
</Route>
|
||||
|
||||
{/* pages */}
|
||||
<Route exact path="/media/:media" component={MediaView} />
|
||||
<Route
|
||||
exact
|
||||
path="/media/:media/:season/:episode"
|
||||
component={MediaView}
|
||||
/>
|
||||
<Route exact path="/search/:type/:query?" component={SearchView} />
|
||||
<Route
|
||||
exact
|
||||
path="/search/:type/:query?"
|
||||
component={SearchView}
|
||||
/>
|
||||
|
||||
{/* other */}
|
||||
<Route exact path="/dev" component={DeveloperView} />
|
||||
<Route exact path="/dev/video" component={VideoTesterView} />
|
||||
<Route
|
||||
exact
|
||||
path="/dev/providers"
|
||||
component={ProviderTesterView}
|
||||
/>
|
||||
<Route exact path="/dev/embeds" component={EmbedTesterView} />
|
||||
<Route path="*" component={NotFoundPage} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
</BannerContextProvider>
|
||||
</BookmarkContextProvider>
|
||||
</WatchedContextProvider>
|
||||
);
|
||||
|
27
src/setup/Layout.tsx
Normal file
27
src/setup/Layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Banner } from "@/components/Banner";
|
||||
import { useBannerSize } from "@/hooks/useBanner";
|
||||
import { useIsOnline } from "@/hooks/usePing";
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function Layout(props: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const isOnline = useIsOnline();
|
||||
const bannerSize = useBannerSize();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="fixed inset-x-0 z-[1000]">
|
||||
{!isOnline ? <Banner type="error">{t("errors.offline")}</Banner> : null}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
paddingTop: `${bannerSize}px`,
|
||||
}}
|
||||
className="flex min-h-screen flex-col"
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -10,8 +10,14 @@ interface Config {
|
||||
NORMAL_ROUTER: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig extends Config {
|
||||
BASE_PROXY_URL: string;
|
||||
export interface RuntimeConfig {
|
||||
APP_VERSION: string;
|
||||
GITHUB_LINK: string;
|
||||
DISCORD_LINK: string;
|
||||
OMDB_API_KEY: string;
|
||||
TMDB_API_KEY: string;
|
||||
NORMAL_ROUTER: boolean;
|
||||
PROXY_URLS: string[];
|
||||
}
|
||||
|
||||
const env: Record<keyof Config, undefined | string> = {
|
||||
@@ -27,12 +33,13 @@ const env: Record<keyof Config, undefined | string> = {
|
||||
const alerts = [] as string[];
|
||||
|
||||
// loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js)
|
||||
function getKey(key: keyof Config): string {
|
||||
function getKey(key: keyof Config, defaultString?: string): string {
|
||||
let windowValue = (window as any)?.__CONFIG__?.[`VITE_${key}`];
|
||||
if (windowValue !== undefined && windowValue.length === 0)
|
||||
windowValue = undefined;
|
||||
const value = env[key] ?? windowValue ?? undefined;
|
||||
if (value === undefined) {
|
||||
if (defaultString) return defaultString;
|
||||
if (!alerts.includes(key)) {
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert(`Misconfigured instance, missing key: ${key}`);
|
||||
@@ -51,8 +58,9 @@ export function conf(): RuntimeConfig {
|
||||
DISCORD_LINK,
|
||||
OMDB_API_KEY: getKey("OMDB_API_KEY"),
|
||||
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",
|
||||
PROXY_URLS: getKey("CORS_PROXY_URL")
|
||||
.split(",")
|
||||
.map((v) => v.trim()),
|
||||
NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true",
|
||||
};
|
||||
}
|
||||
|
@@ -1,3 +1,4 @@
|
||||
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
|
||||
export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
|
||||
export const APP_VERSION = "2.1.3";
|
||||
export const APP_VERSION = "3.0.5";
|
||||
export const GA_ID = "G-44YVXRL61C";
|
||||
|
8
src/setup/ga.ts
Normal file
8
src/setup/ga.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import ReactGA from "react-ga4";
|
||||
import { GA_ID } from "@/setup/constants";
|
||||
|
||||
ReactGA.initialize([
|
||||
{
|
||||
trackingId: GA_ID,
|
||||
},
|
||||
]);
|
@@ -4,12 +4,13 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-denim-100 text-denim-700 font-open-sans overflow-x-hidden;
|
||||
@apply bg-denim-100 font-open-sans text-denim-700 overflow-x-hidden;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
html[data-full], html[data-full] body {
|
||||
html[data-full],
|
||||
html[data-full] body {
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
|
@@ -55,11 +55,14 @@
|
||||
"noVideos": "Whoops, couldn't find any videos for you",
|
||||
"loading": "Loading...",
|
||||
"backToHome": "Back to home",
|
||||
"backToHomeShort": "Back",
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
||||
"buttons": {
|
||||
"episodes": "Episodes",
|
||||
"source": "Source",
|
||||
"captions": "Captions"
|
||||
"captions": "Captions",
|
||||
"download": "Download",
|
||||
"pictureInPicture": "Picture in Picture"
|
||||
},
|
||||
"popouts": {
|
||||
"sources": "Sources",
|
||||
@@ -68,6 +71,8 @@
|
||||
"episode": "E{{index}} - {{title}}",
|
||||
"noCaptions": "No captions",
|
||||
"linkedCaptions": "Linked captions",
|
||||
"customCaption": "Custom caption",
|
||||
"uploadCustomCaption": "Upload caption (SRT, VTT)",
|
||||
"noEmbeds": "No embeds were found for this source",
|
||||
"errors": {
|
||||
"loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}",
|
||||
@@ -87,5 +92,8 @@
|
||||
},
|
||||
"casting": {
|
||||
"casting": "Casting to device..."
|
||||
},
|
||||
"errors": {
|
||||
"offline": "Check your internet connection"
|
||||
}
|
||||
}
|
||||
|
@@ -38,3 +38,11 @@ export function canWebkitFullscreen(): boolean {
|
||||
export function canFullscreen(): boolean {
|
||||
return canFullscreenAnyElement() || canWebkitFullscreen();
|
||||
}
|
||||
|
||||
export function canPictureInPicture(): boolean {
|
||||
return "pictureInPictureEnabled" in document;
|
||||
}
|
||||
|
||||
export function canWebkitPictureInPicture(): boolean {
|
||||
return "webkitSupportsPresentationMode" in document.createElement("video");
|
||||
}
|
||||
|
7
src/utils/normalizeTitle.ts
Normal file
7
src/utils/normalizeTitle.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['":]/g, "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "_");
|
||||
}
|
@@ -1,10 +1,4 @@
|
||||
function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['":]/g, "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "_");
|
||||
}
|
||||
import { normalizeTitle } from "./normalizeTitle";
|
||||
|
||||
export function compareTitle(a: string, b: string): boolean {
|
||||
return normalizeTitle(a) === normalizeTitle(b);
|
||||
|
@@ -30,6 +30,8 @@ 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";
|
||||
import { DownloadAction } from "@/video/components/actions/DownloadAction";
|
||||
import { PictureInPictureAction } from "@/video/components/actions/PictureInPictureAction";
|
||||
|
||||
type Props = VideoPlayerBaseProps;
|
||||
|
||||
@@ -120,6 +122,7 @@ export function VideoPlayer(props: Props) {
|
||||
<HeaderAction
|
||||
showControls={isMobile}
|
||||
onClick={props.onGoBack}
|
||||
isFullScreen
|
||||
/>
|
||||
</Transition>
|
||||
<Transition
|
||||
@@ -141,6 +144,8 @@ export function VideoPlayer(props: Props) {
|
||||
<div className="grid w-full grid-cols-[56px,1fr,56px] items-center">
|
||||
<div />
|
||||
<div className="flex items-center justify-center">
|
||||
<DownloadAction />
|
||||
<PictureInPictureAction />
|
||||
<CaptionsSelectionAction />
|
||||
<SeriesSelectionAction />
|
||||
<SourceSelectionAction />
|
||||
@@ -157,6 +162,8 @@ export function VideoPlayer(props: Props) {
|
||||
<div className="mx-2 h-6 w-px bg-white opacity-50" />
|
||||
<ChromecastAction />
|
||||
<AirplayAction />
|
||||
<DownloadAction />
|
||||
<PictureInPictureAction />
|
||||
<CaptionsSelectionAction />
|
||||
<FullscreenAction />
|
||||
</>
|
||||
|
41
src/video/components/actions/DownloadAction.tsx
Normal file
41
src/video/components/actions/DownloadAction.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useSource } from "@/video/state/logic/source";
|
||||
import { MWStreamType } from "@/backend/helpers/streams";
|
||||
import { normalizeTitle } from "@/utils/normalizeTitle";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DownloadAction(props: Props) {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const sourceInterface = useSource(descriptor);
|
||||
const { isMobile } = useIsMobile();
|
||||
const { t } = useTranslation();
|
||||
const meta = useMeta(descriptor);
|
||||
|
||||
const isHLS = sourceInterface.source?.type === MWStreamType.HLS;
|
||||
|
||||
const title = meta?.meta.meta.title;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={isHLS ? undefined : sourceInterface.source?.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
download={title ? `${normalizeTitle(title)}.mp4` : undefined}
|
||||
>
|
||||
<VideoPlayerIconButton
|
||||
className={props.className}
|
||||
icon={Icons.DOWNLOAD}
|
||||
disabled={isHLS}
|
||||
text={isMobile ? (t("videoPlayer.buttons.download") as string) : ""}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
@@ -5,6 +5,7 @@ import { useMeta } from "@/video/state/logic/meta";
|
||||
interface Props {
|
||||
onClick?: () => void;
|
||||
showControls?: boolean;
|
||||
isFullScreen: boolean;
|
||||
}
|
||||
|
||||
export function HeaderAction(props: Props) {
|
||||
|
40
src/video/components/actions/PictureInPictureAction.tsx
Normal file
40
src/video/components/actions/PictureInPictureAction.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
canPictureInPicture,
|
||||
canWebkitPictureInPicture,
|
||||
} from "@/utils/detectFeatures";
|
||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PictureInPictureAction(props: Props) {
|
||||
const { isMobile } = useIsMobile();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const controls = useControls(descriptor);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
controls.togglePictureInPicture();
|
||||
}, [controls]);
|
||||
|
||||
if (!canPictureInPicture() && !canWebkitPictureInPicture()) return null;
|
||||
|
||||
return (
|
||||
<VideoPlayerIconButton
|
||||
className={props.className}
|
||||
icon={Icons.PICTURE_IN_PICTURE}
|
||||
onClick={handleClick}
|
||||
text={
|
||||
isMobile ? (t("videoPlayer.buttons.pictureInPicture") as string) : ""
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
@@ -9,41 +9,53 @@ import {
|
||||
import { AirplayAction } from "@/video/components/actions/AirplayAction";
|
||||
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useBannerSize } from "@/hooks/useBanner";
|
||||
|
||||
interface VideoPlayerHeaderProps {
|
||||
media?: MWMediaMeta;
|
||||
onClick?: () => void;
|
||||
showControls?: boolean;
|
||||
isFullScreen?: boolean;
|
||||
}
|
||||
|
||||
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
||||
const { isMobile } = useIsMobile();
|
||||
const { bookmarkStore, setItemBookmark } = useBookmarkContext();
|
||||
const isBookmarked = props.media
|
||||
? getIfBookmarkedFromPortable(bookmarkStore.bookmarks, props.media)
|
||||
: false;
|
||||
const showDivider = props.media && props.onClick;
|
||||
const { t } = useTranslation();
|
||||
const bannerHeight = useBannerSize();
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="flex flex-1 items-center">
|
||||
<p className="flex items-center">
|
||||
<div
|
||||
className="flex items-center"
|
||||
style={{
|
||||
paddingTop: props.isFullScreen ? `${bannerHeight}px` : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<p className="flex items-center truncate">
|
||||
{props.onClick ? (
|
||||
<span
|
||||
onClick={props.onClick}
|
||||
className="flex cursor-pointer items-center py-1 text-white opacity-50 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<Icon className="mr-2" icon={Icons.ARROW_LEFT} />
|
||||
{isMobile ? (
|
||||
<span>{t("videoPlayer.backToHomeShort")}</span>
|
||||
) : (
|
||||
<span>{t("videoPlayer.backToHome")}</span>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{showDivider ? (
|
||||
<span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" />
|
||||
) : null}
|
||||
{props.media ? (
|
||||
<span className="flex items-center text-white">
|
||||
<span>{props.media.title}</span>
|
||||
</span>
|
||||
<span className="truncate text-white">{props.media.title}</span>
|
||||
) : null}
|
||||
</p>
|
||||
{props.media && (
|
||||
@@ -64,7 +76,7 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
||||
<ChromecastAction />
|
||||
</>
|
||||
) : (
|
||||
<BrandPill />
|
||||
<BrandPill hideTextOnMobile />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@@ -10,6 +10,7 @@ export interface VideoPlayerIconButtonProps {
|
||||
active?: boolean;
|
||||
wide?: boolean;
|
||||
noPadding?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const VideoPlayerIconButton = forwardRef<
|
||||
@@ -21,17 +22,27 @@ export const VideoPlayerIconButton = forwardRef<
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClick}
|
||||
className="group pointer-events-auto p-2 text-white transition-transform duration-100 active:scale-110"
|
||||
className={[
|
||||
"group pointer-events-auto p-2 text-white transition-transform duration-100 active:scale-110",
|
||||
props.disabled
|
||||
? "pointer-events-none cursor-not-allowed opacity-50"
|
||||
: "",
|
||||
].join(" ")}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
"flex items-center justify-center rounded-full bg-denim-600 bg-opacity-0 transition-colors duration-100 group-hover:bg-opacity-50 group-active:bg-denim-500 group-active:bg-opacity-100",
|
||||
"flex items-center justify-center rounded-full bg-denim-600 bg-opacity-0 transition-colors duration-100",
|
||||
props.active ? "!bg-denim-500 !bg-opacity-100" : "",
|
||||
!props.noPadding ? (props.wide ? "py-2 px-4" : "p-2") : "",
|
||||
!props.disabled
|
||||
? "group-hover:bg-opacity-50 group-active:bg-denim-500 group-active:bg-opacity-100"
|
||||
: "",
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon icon={props.icon} className={props.iconSize ?? "text-2xl"} />
|
||||
<p className="hidden sm:block">
|
||||
{props.text ? <span className="ml-2">{props.text}</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
@@ -1,4 +1,8 @@
|
||||
import { getCaptionUrl } from "@/backend/helpers/captions";
|
||||
import {
|
||||
getCaptionUrl,
|
||||
convertCustomCaptionFileToWebVTT,
|
||||
CUSTOM_CAPTION_ID,
|
||||
} from "@/backend/helpers/captions";
|
||||
import { MWCaption } from "@/backend/helpers/streams";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
@@ -6,7 +10,7 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { useSource } from "@/video/state/logic/source";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { ChangeEvent, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
|
||||
@@ -37,6 +41,29 @@ export function CaptionSelectionPopout() {
|
||||
);
|
||||
|
||||
const currentCaption = source.source?.caption?.id;
|
||||
const customCaptionUploadElement = useRef<HTMLInputElement>(null);
|
||||
const [setCustomCaption, loadingCustomCaption, errorCustomCaption] =
|
||||
useLoading(async (captionFile: File) => {
|
||||
if (
|
||||
!captionFile.name.endsWith(".srt") &&
|
||||
!captionFile.name.endsWith(".vtt")
|
||||
) {
|
||||
throw new Error("Only SRT or VTT files are allowed");
|
||||
}
|
||||
controls.setCaption(
|
||||
CUSTOM_CAPTION_ID,
|
||||
await convertCustomCaptionFileToWebVTT(captionFile)
|
||||
);
|
||||
controls.closePopout();
|
||||
});
|
||||
|
||||
async function handleUploadCaption(e: ChangeEvent<HTMLInputElement>) {
|
||||
if (!e.target.files) {
|
||||
return;
|
||||
}
|
||||
const captionFile = e.target.files[0];
|
||||
setCustomCaption(captionFile);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -54,6 +81,26 @@ export function CaptionSelectionPopout() {
|
||||
>
|
||||
{t("videoPlayer.popouts.noCaptions")}
|
||||
</PopoutListEntry>
|
||||
<PopoutListEntry
|
||||
key={CUSTOM_CAPTION_ID}
|
||||
active={currentCaption === CUSTOM_CAPTION_ID}
|
||||
loading={loadingCustomCaption}
|
||||
errored={!!errorCustomCaption}
|
||||
onClick={() => {
|
||||
customCaptionUploadElement.current?.click();
|
||||
}}
|
||||
>
|
||||
{currentCaption === CUSTOM_CAPTION_ID
|
||||
? t("videoPlayer.popouts.customCaption")
|
||||
: t("videoPlayer.popouts.uploadCustomCaption")}
|
||||
<input
|
||||
ref={customCaptionUploadElement}
|
||||
type="file"
|
||||
onChange={handleUploadCaption}
|
||||
className="hidden"
|
||||
accept=".vtt, .srt"
|
||||
/>
|
||||
</PopoutListEntry>
|
||||
</PopoutSection>
|
||||
|
||||
<p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-200 px-5 py-3 text-sm font-bold uppercase">
|
||||
|
@@ -5,6 +5,7 @@ import { SourceSelectionPopout } from "@/video/components/popouts/SourceSelectio
|
||||
import { CaptionSelectionPopout } from "@/video/components/popouts/CaptionSelectionPopout";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import {
|
||||
useInterface,
|
||||
VideoInterfaceEvent,
|
||||
@@ -37,6 +38,8 @@ function PopoutContainer(props: { videoInterface: VideoInterfaceEvent }) {
|
||||
const [bottom, setBottom] = useState<number>(0);
|
||||
const [width, setWidth] = useState<number>(0);
|
||||
|
||||
const { isMobile } = useIsMobile(true);
|
||||
|
||||
const calculateAndSetCoords = useCallback((rect: DOMRect, w: number) => {
|
||||
const buttonCenter = rect.left + rect.width / 2;
|
||||
|
||||
@@ -57,7 +60,10 @@ function PopoutContainer(props: { videoInterface: VideoInterfaceEvent }) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute z-10 grid h-[500px] w-80 grid-rows-[auto,minmax(0,1fr)] overflow-hidden rounded-lg bg-ash-200"
|
||||
className={[
|
||||
"absolute z-10 grid w-80 grid-rows-[auto,minmax(0,1fr)] overflow-hidden rounded-lg bg-ash-200",
|
||||
isMobile ? "h-[230px]" : " h-[500px]",
|
||||
].join(" ")}
|
||||
style={{
|
||||
right: `${right}px`,
|
||||
bottom: `${bottom}px`,
|
||||
|
@@ -96,7 +96,7 @@ export function PopoutListEntry(props: PopoutListEntryTypes) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"group -mx-2 flex cursor-pointer items-center justify-between space-x-1 rounded p-2 font-semibold transition-[background-color,color] duration-150",
|
||||
"group my-2 -mx-2 flex cursor-pointer items-center justify-between space-x-1 rounded p-2 font-semibold transition-[background-color,color] duration-150",
|
||||
hover,
|
||||
props.active
|
||||
? `${bg} active text-white outline-denim-700`
|
||||
|
@@ -169,8 +169,6 @@ export function SourceSelectionPopout() {
|
||||
return entries;
|
||||
});
|
||||
|
||||
console.log(embedsRes);
|
||||
|
||||
return embedsRes;
|
||||
}, [scrapeResult?.embeds]);
|
||||
|
||||
|
@@ -13,6 +13,7 @@ export type ControlMethods = {
|
||||
setMeta(data?: VideoPlayerMeta): void;
|
||||
setCurrentEpisode(sId: string, eId: string): void;
|
||||
setDraggingTime(num: number): void;
|
||||
togglePictureInPicture(): void;
|
||||
};
|
||||
|
||||
export function useControls(
|
||||
@@ -100,5 +101,9 @@ export function useControls(
|
||||
updateMeta(descriptor, state);
|
||||
}
|
||||
},
|
||||
togglePictureInPicture() {
|
||||
state.stateProvider?.togglePictureInPicture();
|
||||
updateInterface(descriptor, state);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/video/components/hooks/volumeStore";
|
||||
import { resetStateForSource } from "@/video/state/providers/helpers";
|
||||
import { updateInterface } from "@/video/state/logic/interface";
|
||||
import { revokeCaptionBlob } from "@/backend/helpers/captions";
|
||||
import { getPlayerState } from "../cache";
|
||||
import { updateMediaPlaying } from "../logic/mediaplaying";
|
||||
import { VideoPlayerStateProvider } from "./providerTypes";
|
||||
@@ -83,6 +84,9 @@ export function createCastingStateProvider(
|
||||
state.pausedWhenSeeking = state.mediaPlaying.isPaused;
|
||||
this.pause();
|
||||
},
|
||||
togglePictureInPicture() {
|
||||
// no picture in picture while casting
|
||||
},
|
||||
async setVolume(v) {
|
||||
// clamp time between 0 and 1
|
||||
let volume = Math.min(v, 1);
|
||||
@@ -135,6 +139,7 @@ export function createCastingStateProvider(
|
||||
},
|
||||
setCaption(id, url) {
|
||||
if (state.source) {
|
||||
revokeCaptionBlob(state.source.caption?.url);
|
||||
state.source.caption = {
|
||||
id,
|
||||
url,
|
||||
@@ -144,6 +149,7 @@ export function createCastingStateProvider(
|
||||
},
|
||||
clearCaption() {
|
||||
if (state.source) {
|
||||
revokeCaptionBlob(state.source.caption?.url);
|
||||
state.source.caption = null;
|
||||
updateSource(descriptor, state);
|
||||
}
|
||||
|
@@ -19,6 +19,7 @@ export type VideoPlayerStateController = {
|
||||
setCaption(id: string, url: string): void;
|
||||
clearCaption(): void;
|
||||
getId(): string;
|
||||
togglePictureInPicture(): void;
|
||||
};
|
||||
|
||||
export type VideoPlayerStateProvider = VideoPlayerStateController & {
|
||||
|
@@ -5,6 +5,8 @@ import {
|
||||
canFullscreen,
|
||||
canFullscreenAnyElement,
|
||||
canWebkitFullscreen,
|
||||
canPictureInPicture,
|
||||
canWebkitPictureInPicture,
|
||||
} from "@/utils/detectFeatures";
|
||||
import { MWStreamType } from "@/backend/helpers/streams";
|
||||
import { updateInterface } from "@/video/state/logic/interface";
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
import { updateError } from "@/video/state/logic/error";
|
||||
import { updateMisc } from "@/video/state/logic/misc";
|
||||
import { resetStateForSource } from "@/video/state/providers/helpers";
|
||||
import { revokeCaptionBlob } from "@/backend/helpers/captions";
|
||||
import { getPlayerState } from "../cache";
|
||||
import { updateMediaPlaying } from "../logic/mediaplaying";
|
||||
import { VideoPlayerStateProvider } from "./providerTypes";
|
||||
@@ -191,6 +194,7 @@ export function createVideoStateProvider(
|
||||
},
|
||||
setCaption(id, url) {
|
||||
if (state.source) {
|
||||
revokeCaptionBlob(state.source.caption?.url);
|
||||
state.source.caption = {
|
||||
id,
|
||||
url,
|
||||
@@ -200,10 +204,28 @@ export function createVideoStateProvider(
|
||||
},
|
||||
clearCaption() {
|
||||
if (state.source) {
|
||||
revokeCaptionBlob(state.source.caption?.url);
|
||||
state.source.caption = null;
|
||||
updateSource(descriptor, state);
|
||||
}
|
||||
},
|
||||
togglePictureInPicture() {
|
||||
if (canWebkitPictureInPicture()) {
|
||||
const webkitPlayer = player as any;
|
||||
webkitPlayer.webkitSetPresentationMode(
|
||||
webkitPlayer.webkitPresentationMode === "picture-in-picture"
|
||||
? "inline"
|
||||
: "picture-in-picture"
|
||||
);
|
||||
}
|
||||
if (canPictureInPicture()) {
|
||||
if (player !== document.pictureInPictureElement) {
|
||||
player.requestPictureInPicture();
|
||||
} else {
|
||||
document.exitPictureInPicture();
|
||||
}
|
||||
}
|
||||
},
|
||||
providerStart() {
|
||||
this.setVolume(getStoredVolume());
|
||||
|
||||
|
26
src/views/developer/DeveloperView.tsx
Normal file
26
src/views/developer/DeveloperView.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Navigation } from "@/components/layout/Navigation";
|
||||
import { ThinContainer } from "@/components/layout/ThinContainer";
|
||||
import { ArrowLink } from "@/components/text/ArrowLink";
|
||||
import { Title } from "@/components/text/Title";
|
||||
|
||||
export function DeveloperView() {
|
||||
return (
|
||||
<div className="py-48">
|
||||
<Navigation />
|
||||
<ThinContainer classNames="flex flex-col space-y-4">
|
||||
<Title className="mb-8">Developer tools</Title>
|
||||
<ArrowLink
|
||||
to="/dev/providers"
|
||||
direction="right"
|
||||
linkText="Provider tester"
|
||||
/>
|
||||
<ArrowLink
|
||||
to="/dev/embeds"
|
||||
direction="right"
|
||||
linkText="Embed scraper tester"
|
||||
/>
|
||||
<ArrowLink to="/dev/video" direction="right" linkText="Video tester" />
|
||||
</ThinContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
136
src/views/developer/EmbedTesterView.tsx
Normal file
136
src/views/developer/EmbedTesterView.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { MWEmbed, MWEmbedScraper, MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { getEmbeds } from "@/backend/helpers/register";
|
||||
import { runEmbedScraper } from "@/backend/helpers/run";
|
||||
import { MWStream } from "@/backend/helpers/streams";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Navigation } from "@/components/layout/Navigation";
|
||||
import { ArrowLink } from "@/components/text/ArrowLink";
|
||||
import { Title } from "@/components/text/Title";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
interface MediaSelectorProps {
|
||||
embedType: MWEmbedType;
|
||||
onSelect: (meta: MWEmbed) => void;
|
||||
}
|
||||
|
||||
interface EmbedScraperSelectorProps {
|
||||
onSelect: (embedScraperId: string) => void;
|
||||
}
|
||||
|
||||
interface MediaScraperProps {
|
||||
embed: MWEmbed;
|
||||
scraper: MWEmbedScraper;
|
||||
}
|
||||
|
||||
function MediaSelector(props: MediaSelectorProps) {
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
const select = useCallback(
|
||||
(urlSt: string) => {
|
||||
props.onSelect({
|
||||
type: props.embedType,
|
||||
url: urlSt,
|
||||
});
|
||||
},
|
||||
[props]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Title className="mb-8">Input embed url</Title>
|
||||
<div className="mb-4 flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="embed url here..."
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<Button onClick={() => select(url)}>Run scraper</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaScraper(props: MediaScraperProps) {
|
||||
const [results, setResults] = useState<MWStream | null>(null);
|
||||
const [percentage, setPercentage] = useState(0);
|
||||
|
||||
const [scrape, loading, error] = useLoading(async (url: string) => {
|
||||
const data = await runEmbedScraper(props.scraper, {
|
||||
url,
|
||||
progress(num) {
|
||||
console.log(`SCRAPING AT ${num}%`);
|
||||
setPercentage(num);
|
||||
},
|
||||
});
|
||||
console.log("got data", data);
|
||||
setResults(data);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (props.embed) {
|
||||
scrape(props.embed.url);
|
||||
}
|
||||
}, [props.embed, scrape]);
|
||||
|
||||
if (loading) return <p>Scraping... ({percentage}%)</p>;
|
||||
if (error) return <p>Errored, check console</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title className="mb-8">Output data</Title>
|
||||
<code>
|
||||
<pre>{JSON.stringify(results, null, 2)}</pre>
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmbedScraperSelector(props: EmbedScraperSelectorProps) {
|
||||
const embedScrapers = getEmbeds();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Title className="mb-8">Choose embed scraper</Title>
|
||||
{embedScrapers.map((v) => (
|
||||
<ArrowLink
|
||||
key={v.id}
|
||||
onClick={() => props.onSelect(v.id)}
|
||||
direction="right"
|
||||
linkText={v.displayName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmbedTesterView() {
|
||||
const [embed, setEmbed] = useState<MWEmbed | null>(null);
|
||||
const [embedScraperId, setEmbedScraperId] = useState<string | null>(null);
|
||||
const embedScraper = useMemo(
|
||||
() => getEmbeds().find((v) => v.id === embedScraperId),
|
||||
[embedScraperId]
|
||||
);
|
||||
|
||||
let content: ReactNode = null;
|
||||
if (!embedScraperId || !embedScraper) {
|
||||
content = <EmbedScraperSelector onSelect={(id) => setEmbedScraperId(id)} />;
|
||||
} else if (!embed) {
|
||||
content = (
|
||||
<MediaSelector
|
||||
embedType={embedScraper.for}
|
||||
onSelect={(v) => setEmbed(v)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = <MediaScraper scraper={embedScraper} embed={embed} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-48">
|
||||
<Navigation />
|
||||
<div className="mx-8 overflow-x-auto">{content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
118
src/views/developer/ProviderTesterView.tsx
Normal file
118
src/views/developer/ProviderTesterView.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { MWProviderScrapeResult } from "@/backend/helpers/provider";
|
||||
import { getProviders } from "@/backend/helpers/register";
|
||||
import { runProvider } from "@/backend/helpers/run";
|
||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||
import { Navigation } from "@/components/layout/Navigation";
|
||||
import { ArrowLink } from "@/components/text/ArrowLink";
|
||||
import { Title } from "@/components/text/Title";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { testData } from "@/__tests__/providers/testdata";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
|
||||
interface MediaSelectorProps {
|
||||
onSelect: (meta: DetailedMeta) => void;
|
||||
}
|
||||
|
||||
interface ProviderSelectorProps {
|
||||
onSelect: (providerId: string) => void;
|
||||
}
|
||||
|
||||
interface MediaScraperProps {
|
||||
media: DetailedMeta | null;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function MediaSelector(props: MediaSelectorProps) {
|
||||
const options: DetailedMeta[] = testData;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Title className="mb-8">Choose media</Title>
|
||||
{options.map((v) => (
|
||||
<ArrowLink
|
||||
key={v.imdbId}
|
||||
onClick={() => props.onSelect(v)}
|
||||
direction="right"
|
||||
linkText={`${v.meta.title} (${v.meta.type})`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaScraper(props: MediaScraperProps) {
|
||||
const [results, setResults] = useState<MWProviderScrapeResult | null>(null);
|
||||
const [percentage, setPercentage] = useState(0);
|
||||
|
||||
const [scrape, loading, error] = useLoading(async (media: DetailedMeta) => {
|
||||
const provider = getProviders().find((v) => v.id === props.id);
|
||||
if (!provider) throw new Error("provider not found");
|
||||
const data = await runProvider(provider, {
|
||||
progress(num) {
|
||||
console.log(`SCRAPING AT ${num}%`);
|
||||
setPercentage(num);
|
||||
},
|
||||
media,
|
||||
type: media.meta.type as any,
|
||||
});
|
||||
console.log("got data", data);
|
||||
setResults(data);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (props.media) {
|
||||
scrape(props.media);
|
||||
}
|
||||
}, [props.media, scrape]);
|
||||
|
||||
if (loading) return <p>Scraping... ({percentage}%)</p>;
|
||||
if (error) return <p>Errored, check console</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title className="mb-8">Output data</Title>
|
||||
<code>
|
||||
<pre>{JSON.stringify(results, null, 2)}</pre>
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderSelector(props: ProviderSelectorProps) {
|
||||
const providers = getProviders();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Title className="mb-8">Choose provider</Title>
|
||||
{providers.map((v) => (
|
||||
<ArrowLink
|
||||
key={v.id}
|
||||
onClick={() => props.onSelect(v.id)}
|
||||
direction="right"
|
||||
linkText={v.displayName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderTesterView() {
|
||||
const [media, setMedia] = useState<DetailedMeta | null>(null);
|
||||
const [providerId, setProviderId] = useState<string | null>(null);
|
||||
|
||||
let content: ReactNode = null;
|
||||
if (!providerId) {
|
||||
content = <ProviderSelector onSelect={(id) => setProviderId(id)} />;
|
||||
} else if (!media) {
|
||||
content = <MediaSelector onSelect={(v) => setMedia(v)} />;
|
||||
} else {
|
||||
content = <MediaScraper id={providerId} media={media} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-48">
|
||||
<Navigation />
|
||||
<div className="mx-8 overflow-x-auto">{content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
111
src/views/developer/VideoTesterView.tsx
Normal file
111
src/views/developer/VideoTesterView.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Dropdown } from "@/components/Dropdown";
|
||||
import { Navigation } from "@/components/layout/Navigation";
|
||||
import { ThinContainer } from "@/components/layout/ThinContainer";
|
||||
import { MetaController } from "@/video/components/controllers/MetaController";
|
||||
import { SourceController } from "@/video/components/controllers/SourceController";
|
||||
import { VideoPlayer } from "@/video/components/VideoPlayer";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
interface VideoData {
|
||||
streamUrl: string;
|
||||
type: MWStreamType;
|
||||
}
|
||||
|
||||
const testData: VideoData = {
|
||||
streamUrl:
|
||||
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
|
||||
type: MWStreamType.MP4,
|
||||
};
|
||||
const testMeta: DetailedMeta = {
|
||||
imdbId: "",
|
||||
tmdbId: "",
|
||||
meta: {
|
||||
id: "hello-world",
|
||||
title: "Big Buck Bunny",
|
||||
type: MWMediaType.MOVIE,
|
||||
seasons: undefined,
|
||||
year: "2000",
|
||||
},
|
||||
};
|
||||
|
||||
export function VideoTesterView() {
|
||||
const [video, setVideo] = useState<VideoData | null>(null);
|
||||
const [videoType, setVideoType] = useState<MWStreamType>(MWStreamType.MP4);
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
const playVideo = useCallback(
|
||||
(streamUrl: string) => {
|
||||
setVideo({
|
||||
streamUrl,
|
||||
type: videoType,
|
||||
});
|
||||
},
|
||||
[videoType]
|
||||
);
|
||||
|
||||
if (video) {
|
||||
return (
|
||||
<div className="fixed top-0 left-0 h-[100dvh] w-screen">
|
||||
<Helmet>
|
||||
<html data-full="true" />
|
||||
</Helmet>
|
||||
<VideoPlayer includeSafeArea autoPlay onGoBack={() => setVideo(null)}>
|
||||
<MetaController
|
||||
data={{
|
||||
captions: [],
|
||||
meta: testMeta,
|
||||
}}
|
||||
linkedCaptions={[]}
|
||||
/>
|
||||
<SourceController
|
||||
source={video.streamUrl}
|
||||
type={MWStreamType.MP4}
|
||||
quality={MWStreamQuality.Q720P}
|
||||
/>
|
||||
</VideoPlayer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-64">
|
||||
<Navigation />
|
||||
<ThinContainer classNames="flex items-start flex-col space-y-4">
|
||||
<div className="w-48">
|
||||
<Dropdown
|
||||
options={[
|
||||
{ id: MWStreamType.MP4, name: "Mp4" },
|
||||
{ id: MWStreamType.HLS, name: "hls/m3u8" },
|
||||
]}
|
||||
selectedItem={{ id: videoType, name: videoType }}
|
||||
setSelectedItem={(a) => setVideoType(a.id as MWStreamType)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="stream url here..."
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<Button onClick={() => playVideo(url)}>Play video</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setVideo({
|
||||
streamUrl: testData.streamUrl,
|
||||
type: testData.type,
|
||||
})
|
||||
}
|
||||
>
|
||||
Play default video
|
||||
</Button>
|
||||
</ThinContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -9,7 +9,7 @@ export function MediaFetchErrorView() {
|
||||
const goBack = useGoBack();
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1">
|
||||
<div className="flex-1">
|
||||
<Helmet>
|
||||
<title>{t("media.errors.failedMeta")}</title>
|
||||
</Helmet>
|
||||
|
@@ -28,11 +28,11 @@ function MediaViewLoading(props: { onGoBack(): void }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen items-center justify-center">
|
||||
<div className="relative flex flex-1 items-center justify-center">
|
||||
<Helmet>
|
||||
<title>{t("videoPlayer.loading")}</title>
|
||||
</Helmet>
|
||||
<div className="absolute inset-x-0 top-0 p-6">
|
||||
<div className="absolute inset-x-0 top-0 py-6 px-8">
|
||||
<VideoPlayerHeader onClick={props.onGoBack} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -62,7 +62,7 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
|
||||
}, [stream, props]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen items-center justify-center">
|
||||
<div className="relative flex flex-1 items-center justify-center">
|
||||
<Helmet>
|
||||
<title>{props.meta.meta.title}</title>
|
||||
</Helmet>
|
||||
|
@@ -17,18 +17,18 @@ export function NotFoundWrapper(props: {
|
||||
const goBack = useGoBack();
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1">
|
||||
<div className="relative flex flex-1 flex-col">
|
||||
<Helmet>
|
||||
<title>{t("notFound.genericTitle")}</title>
|
||||
</Helmet>
|
||||
{props.video ? (
|
||||
<div className="fixed inset-x-0 top-0 py-6 px-8">
|
||||
<div className="absolute inset-x-0 top-0 py-6 px-8">
|
||||
<VideoPlayerHeader onClick={goBack} />
|
||||
</div>
|
||||
) : (
|
||||
<Navigation />
|
||||
)}
|
||||
<div className="flex h-full flex-col items-center justify-center p-5 text-center">
|
||||
<div className="flex h-full flex-1 flex-col items-center justify-center p-5 text-center">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -11,6 +11,52 @@ function fromBinary(str: string): Uint8Array {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function importV2Data({ data, time }: { data: any; time: Date }) {
|
||||
const savedTime = localStorage.getItem("mw-migration-date");
|
||||
if (savedTime) {
|
||||
if (new Date(savedTime) >= time) {
|
||||
// has already migrated this or something newer, skip
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// restore migration data
|
||||
if (data.bookmarks)
|
||||
localStorage.setItem("mw-bookmarks", JSON.stringify(data.bookmarks));
|
||||
if (data.videoProgress)
|
||||
localStorage.setItem("video-progress", JSON.stringify(data.videoProgress));
|
||||
|
||||
localStorage.setItem("mw-migration-date", time.toISOString());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function EmbedMigration() {
|
||||
let hasReceivedMigrationData = false;
|
||||
|
||||
const onMessage = (e: any) => {
|
||||
const data = e.data;
|
||||
if (data && data.isMigrationData && !hasReceivedMigrationData) {
|
||||
hasReceivedMigrationData = true;
|
||||
const didImport = importV2Data({
|
||||
data: data.data,
|
||||
time: data.date,
|
||||
});
|
||||
if (didImport) window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("message", onMessage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", onMessage);
|
||||
};
|
||||
});
|
||||
|
||||
return <iframe src="https://movie.squeezebox.dev" hidden />;
|
||||
}
|
||||
|
||||
export function V2MigrationView() {
|
||||
const [done, setDone] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -28,24 +74,10 @@ export function V2MigrationView() {
|
||||
);
|
||||
const timeOfMigration = new Date(params.get("m-time") as string);
|
||||
|
||||
const savedTime = localStorage.getItem("mw-migration-date");
|
||||
if (savedTime) {
|
||||
if (new Date(savedTime) >= timeOfMigration) {
|
||||
// has already migrated this or something newer, skip
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// restore migration data
|
||||
if (data.bookmarks)
|
||||
localStorage.setItem("mw-bookmarks", JSON.stringify(data.bookmarks));
|
||||
if (data.videoProgress)
|
||||
localStorage.setItem(
|
||||
"video-progress",
|
||||
JSON.stringify(data.videoProgress)
|
||||
);
|
||||
localStorage.setItem("mw-migration-date", timeOfMigration.toISOString());
|
||||
importV2Data({
|
||||
data,
|
||||
time: timeOfMigration,
|
||||
});
|
||||
|
||||
// finished
|
||||
setDone(true);
|
||||
|
@@ -14,6 +14,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Modal, ModalCard } from "@/components/layout/Modal";
|
||||
import { Button } from "@/components/Button";
|
||||
import { EmbedMigration } from "../other/v2Migration";
|
||||
|
||||
function Bookmarks() {
|
||||
const { t } = useTranslation();
|
||||
@@ -171,7 +172,8 @@ function NewDomainModal() {
|
||||
|
||||
export function HomeView() {
|
||||
return (
|
||||
<div className="mb-16 mt-32">
|
||||
<div className="mb-16">
|
||||
<EmbedMigration />
|
||||
<NewDomainModal />
|
||||
<Bookmarks />
|
||||
<Watched />
|
||||
|
@@ -7,6 +7,7 @@ import { SearchBarInput } from "@/components/SearchBar";
|
||||
import { Title } from "@/components/text/Title";
|
||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { useBannerSize } from "@/hooks/useBanner";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { SearchResultsPartial } from "./SearchResultsPartial";
|
||||
|
||||
@@ -14,6 +15,7 @@ export function SearchView() {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch, setSearchUnFocus] = useSearchQuery();
|
||||
const [showBg, setShowBg] = useState(false);
|
||||
const bannerSize = useBannerSize();
|
||||
|
||||
const stickStateChanged = useCallback(
|
||||
({ status }: Sticky.Status) => setShowBg(status === Sticky.STATUS_FIXED),
|
||||
@@ -22,7 +24,7 @@ export function SearchView() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-10 mb-24">
|
||||
<div className="relative z-10 mb-16 sm:mb-24">
|
||||
<Helmet>
|
||||
<title>{t("global.name")}</title>
|
||||
</Helmet>
|
||||
@@ -32,11 +34,15 @@ export function SearchView() {
|
||||
<div className="absolute left-0 bottom-0 right-0 flex h-0 justify-center">
|
||||
<div className="absolute bottom-4 h-[100vh] w-[3000px] rounded-[100%] bg-denim-300 md:w-[200vw]" />
|
||||
</div>
|
||||
<div className="relative z-20">
|
||||
<div className="mb-16">
|
||||
<div className="relative z-10 mb-16">
|
||||
<Title className="mx-auto max-w-xs">{t("search.title")}</Title>
|
||||
</div>
|
||||
<Sticky enabled top={16} onStateChange={stickStateChanged}>
|
||||
<div className="relative z-30">
|
||||
<Sticky
|
||||
enabled
|
||||
top={16 + bannerSize}
|
||||
onStateChange={stickStateChanged}
|
||||
>
|
||||
<SearchBarInput
|
||||
onChange={setSearch}
|
||||
value={search}
|
||||
|
@@ -19,7 +19,7 @@
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
"types": ["vite/client", "vite-plugin-pwa/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
@@ -1,12 +1,60 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import loadVersion from "vite-plugin-package-version";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import checker from "vite-plugin-checker";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
injectRegister: "inline",
|
||||
workbox: {
|
||||
globIgnores: ["**ping.txt**"],
|
||||
},
|
||||
includeAssets: [
|
||||
"favicon.ico",
|
||||
"apple-touch-icon.png",
|
||||
"safari-pinned-tab.svg",
|
||||
],
|
||||
manifest: {
|
||||
name: "movie-web",
|
||||
short_name: "movie-web",
|
||||
description: "The place for your favourite movies & shows",
|
||||
theme_color: "#120f1d",
|
||||
background_color: "#120f1d",
|
||||
display: "standalone",
|
||||
start_url: "/",
|
||||
icons: [
|
||||
{
|
||||
src: "android-chrome-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "android-chrome-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "android-chrome-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
src: "android-chrome-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
loadVersion(),
|
||||
checker({
|
||||
typescript: true, // check typescript build errors in dev server
|
||||
@@ -24,4 +72,8 @@ export default defineConfig({
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
},
|
||||
});
|
||||
|
Reference in New Issue
Block a user