mirror of
https://github.com/movie-web/movie-web.git
synced 2025-09-13 15:43:24 +00:00
Compare commits
146 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
3ed5dcfc15 | ||
|
71235f5174 | ||
|
0d79a677a0 | ||
|
a34d245e2b | ||
|
8b8cbc8cc9 | ||
|
5ee4f013ff | ||
|
99a3e6db69 | ||
|
7d3e1c0943 | ||
|
2cfd7e64a2 | ||
|
d6def996bf | ||
|
8bba2961b4 | ||
|
da05a2597e | ||
|
d40076e950 | ||
|
bb4a6d8a1e | ||
|
7007f030e1 | ||
|
24fa1c449f | ||
|
591b1d3bc5 | ||
|
c162f15496 | ||
|
2650707d2c | ||
|
a0a51c898a | ||
|
43c8da9003 | ||
|
1472b21600 | ||
|
2424cdfc9e | ||
|
2239c186a5 | ||
|
0c2df2cd3c | ||
|
b26b0715bd | ||
|
7b75c36d21 | ||
|
e52b29a1a1 | ||
|
12c245b2da | ||
|
871780f95e | ||
|
fa985fc2c2 | ||
|
db9eec195a | ||
|
de1221235b | ||
|
b576a298e8 | ||
|
fcb24c783c | ||
c5251401e7 | |||
41fd23cf20 | |||
|
5dfeeadbb8 | ||
|
0794558338 | ||
|
d2ffa35f2c | ||
c330112dbc | |||
84b8a67cea | |||
|
546b008b2e | ||
|
b9b0380dfe | ||
|
c472e7f7b8 | ||
|
3decc9190c | ||
|
184af19498 | ||
|
2eab07b8b6 | ||
|
5d8f03b859 | ||
|
2178057633 | ||
|
9e961223f6 | ||
|
c2b52d3db8 | ||
|
06a44da9cc | ||
|
49d7dc9761 | ||
|
1585805d86 | ||
|
7dc76e993f | ||
|
661d995e3b | ||
|
156b693460 | ||
|
d82b32e8d9 | ||
|
8a8dbb2778 | ||
|
6d95f83c0b | ||
|
2fe53a05e8 | ||
|
495222eb10 | ||
|
119bafa516 | ||
|
ba1ee0267b | ||
|
92ef687ddc | ||
|
5e776f8655 | ||
|
c541d4212a | ||
2d17c8abaa | |||
|
4a52fc11ed | ||
|
54d1af0e0a | ||
|
48f54dd7cc | ||
|
3a44eb550d | ||
|
0fa3d3e430 | ||
|
a9849b40c2 | ||
|
80954514b6 | ||
|
e2dd74c0af | ||
|
2f10de415b | ||
|
efcb12f95a | ||
|
307f555b70 | ||
|
4d5f03337d | ||
|
9f008f02d1 | ||
|
e91f65dd91 | ||
|
3aab008f12 | ||
|
659b0168c3 | ||
|
e9e2129aa2 | ||
|
bed3318ebe | ||
|
436a2388b9 | ||
|
1ad1c69d3e | ||
|
fac2b50bfc | ||
|
4d08ecc694 | ||
|
5edc99cdfe | ||
|
3b0232b3d6 | ||
|
f2ea05708f | ||
|
772777835e | ||
|
dc58c2b55e | ||
|
c7f3f774bb | ||
|
96656d9a2f | ||
|
5419430369 | ||
|
603e42b907 | ||
|
d51603a382 | ||
|
731ef6a9aa | ||
|
0de9551080 | ||
|
0f7c51c198 | ||
|
cf2060bd32 | ||
|
ec73d5ef90 | ||
|
9c159f01bd | ||
|
215b5920c3 | ||
|
6136ff92e6 | ||
|
51dfef18fb | ||
|
12f7f2ee03 | ||
|
01f46ce23c | ||
|
ffe817388a | ||
|
37d5aaede9 | ||
|
e2b1a9bfde | ||
|
827d4b576b | ||
|
5664540acc | ||
|
4fe7f1fd1c | ||
|
12555a5933 | ||
|
9fe7bdcf47 | ||
|
20addc039c | ||
|
9dad4e687d | ||
|
870aa4f105 | ||
|
464b78d914 | ||
|
06d043d482 | ||
|
01f98c583a | ||
|
f0c9103e0d | ||
|
53a0168615 | ||
|
c9ccf018f2 | ||
|
fec1d5ac15 | ||
|
9bedf2b9f1 | ||
|
57ac2ac677 | ||
|
60a5f84f2f | ||
|
f2efd828dc | ||
|
8e79e3acdb | ||
|
31cd4d3c75 | ||
|
dfe1dd53b7 | ||
|
c2d09566b0 | ||
|
3bee46ff53 | ||
|
315c3de3ab | ||
|
007375c1df | ||
|
bd26ed5bc0 | ||
|
ef4cb064e7 | ||
|
875be16c4c | ||
|
f264457c57 | ||
|
7bf1d05f16 |
48
.github/workflows/linting_annotate.yml
vendored
48
.github/workflows/linting_annotate.yml
vendored
@@ -1,48 +0,0 @@
|
||||
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"
|
11
.github/workflows/linting_testing.yml
vendored
11
.github/workflows/linting_testing.yml
vendored
@@ -25,15 +25,8 @@ jobs:
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
|
||||
- name: Run ESLint Report
|
||||
run: yarn lint:report
|
||||
# continue on error, so it still reports it in the next step
|
||||
continue-on-error: true
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: eslint_report.json
|
||||
path: eslint_report.json
|
||||
- name: Run ESLint
|
||||
run: yarn lint
|
||||
|
||||
building:
|
||||
name: Build project
|
||||
|
28
package.json
28
package.json
@@ -1,14 +1,18 @@
|
||||
{
|
||||
"name": "movie-web",
|
||||
"version": "3.0.6",
|
||||
"version": "3.0.12",
|
||||
"private": true,
|
||||
"homepage": "https://movie.squeezebox.dev",
|
||||
"homepage": "https://movie-web.app",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"@react-spring/web": "^9.7.1",
|
||||
"@sentry/integrations": "^7.49.0",
|
||||
"@sentry/react": "^7.49.0",
|
||||
"@use-gesture/react": "^10.2.24",
|
||||
"core-js": "^3.29.1",
|
||||
"crypto-js": "^4.1.1",
|
||||
"dompurify": "^3.0.1",
|
||||
"fscreen": "^1.2.0",
|
||||
"fuse.js": "^6.4.6",
|
||||
"hls.js": "^1.0.7",
|
||||
@@ -28,7 +32,7 @@
|
||||
"react-stickynode": "^4.1.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-use": "^17.4.0",
|
||||
"srt-webvtt": "^2.0.0",
|
||||
"subsrt-ts": "^2.1.0",
|
||||
"unpacker": "^1.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -42,9 +46,8 @@
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
"defaults",
|
||||
"chrome > 90"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
@@ -53,22 +56,27 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.21.3",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"@babel/preset-typescript": "^7.21.0",
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@types/chromecast-caf-sender": "^1.0.5",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/dompurify": "^2.4.0",
|
||||
"@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-helmet": "^6.1.6",
|
||||
"@types/react-router": "^5.1.20",
|
||||
"@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",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"eslint": "^8.10.0",
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
@@ -91,7 +99,7 @@
|
||||
"vite-plugin-package-version": "^1.0.2",
|
||||
"vite-plugin-pwa": "^0.14.4",
|
||||
"vitest": "^0.28.5",
|
||||
"workbox-window": "^6.5.4",
|
||||
"@types/react-helmet": "^6.1.6"
|
||||
"workbox-build": "^6.5.4",
|
||||
"workbox-window": "^6.5.4"
|
||||
}
|
||||
}
|
||||
|
@@ -1,46 +1,28 @@
|
||||
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
|
||||
import toWebVTT from "srt-webvtt";
|
||||
import { MWCaption } from "@/backend/helpers/streams";
|
||||
import DOMPurify from "dompurify";
|
||||
import { parse, detect, list } from "subsrt-ts";
|
||||
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||
|
||||
export const CUSTOM_CAPTION_ID = "customCaption";
|
||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
if (caption.type === MWCaptionType.SRT) {
|
||||
let captionBlob: Blob;
|
||||
|
||||
if (caption.needsProxy) {
|
||||
captionBlob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
} else {
|
||||
captionBlob = await mwFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
}
|
||||
|
||||
return toWebVTT(captionBlob);
|
||||
}
|
||||
|
||||
if (caption.type === MWCaptionType.VTT) {
|
||||
if (caption.needsProxy) {
|
||||
const blob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
return caption.url;
|
||||
}
|
||||
|
||||
throw new Error("invalid type");
|
||||
export const customCaption = "external-custom";
|
||||
export function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
|
||||
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
|
||||
}
|
||||
|
||||
export async function convertCustomCaptionFileToWebVTT(file: File) {
|
||||
const header = await file.slice(0, 6).text();
|
||||
const isWebVTT = header === "WEBVTT";
|
||||
if (!isWebVTT) {
|
||||
return toWebVTT(file);
|
||||
export const subtitleTypeList = list().map((type) => `.${type}`);
|
||||
export const sanitize = DOMPurify.sanitize;
|
||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
if (caption.url.startsWith("blob:")) return caption.url;
|
||||
let captionBlob: Blob;
|
||||
if (caption.needsProxy) {
|
||||
captionBlob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
} else {
|
||||
captionBlob = await mwFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
}
|
||||
return URL.createObjectURL(file);
|
||||
return URL.createObjectURL(captionBlob);
|
||||
}
|
||||
|
||||
export function revokeCaptionBlob(url: string | undefined) {
|
||||
@@ -48,3 +30,12 @@ export function revokeCaptionBlob(url: string | undefined) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSubtitles(text: string): ContentCaption[] {
|
||||
if (detect(text) === "") {
|
||||
throw new Error("Invalid subtitle format");
|
||||
}
|
||||
return parse(text).filter(
|
||||
(cue) => cue.type === "caption"
|
||||
) as ContentCaption[];
|
||||
}
|
||||
|
@@ -6,6 +6,7 @@ export enum MWStreamType {
|
||||
export enum MWCaptionType {
|
||||
VTT = "vtt",
|
||||
SRT = "srt",
|
||||
UNKNOWN = "unknown",
|
||||
}
|
||||
|
||||
export enum MWStreamQuality {
|
||||
|
@@ -1,11 +1,12 @@
|
||||
import { initializeScraperStore } from "./helpers/register";
|
||||
|
||||
// providers
|
||||
import "./providers/gdriveplayer";
|
||||
// import "./providers/gdriveplayer";
|
||||
import "./providers/flixhq";
|
||||
import "./providers/superstream";
|
||||
import "./providers/netfilm";
|
||||
import "./providers/m4ufree";
|
||||
import "./providers/hdwatched";
|
||||
|
||||
// embeds
|
||||
import "./embeds/streamm4u";
|
||||
|
@@ -21,7 +21,7 @@ export type JWMediaResult = {
|
||||
title: string;
|
||||
poster?: string;
|
||||
id: number;
|
||||
original_release_year: number;
|
||||
original_release_year?: number;
|
||||
jw_entity_id: string;
|
||||
object_type: JWContentTypes;
|
||||
seasons?: JWSeasonShort[];
|
||||
@@ -67,7 +67,7 @@ export function formatJWMeta(
|
||||
return {
|
||||
title: media.title,
|
||||
id: media.id.toString(),
|
||||
year: media.original_release_year.toString(),
|
||||
year: media.original_release_year?.toString(),
|
||||
poster: media.poster
|
||||
? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}`
|
||||
: undefined,
|
||||
|
@@ -24,7 +24,7 @@ export type MWSeasonWithEpisodeMeta = {
|
||||
type MWMediaMetaBase = {
|
||||
title: string;
|
||||
id: string;
|
||||
year: string;
|
||||
year?: string;
|
||||
poster?: string;
|
||||
};
|
||||
|
||||
|
@@ -8,25 +8,15 @@ import {
|
||||
} from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
|
||||
// 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";
|
||||
const flixHqBase = "https://api.consumet.org/meta/tmdb";
|
||||
|
||||
type FlixHQMediaType = "Movie" | "TV Series";
|
||||
interface FLIXMediaBase {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface FLIXTVSerie extends FLIXMediaBase {
|
||||
type: "TV Series";
|
||||
seasons: number | null;
|
||||
}
|
||||
|
||||
interface FLIXMovie extends FLIXMediaBase {
|
||||
type: "Movie";
|
||||
type: FlixHQMediaType;
|
||||
releaseDate: string;
|
||||
}
|
||||
|
||||
@@ -49,13 +39,17 @@ const qualityMap: Record<string, MWStreamQuality> = {
|
||||
"1080": MWStreamQuality.Q1080P,
|
||||
};
|
||||
|
||||
function flixTypeToMWType(type: FlixHQMediaType) {
|
||||
if (type === "Movie") return MWMediaType.MOVIE;
|
||||
return MWMediaType.SERIES;
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "flixhq",
|
||||
displayName: "FlixHQ",
|
||||
rank: 100,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, progress }) {
|
||||
async scrape({ media, episode, progress }) {
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
@@ -66,42 +60,48 @@ registerProvider({
|
||||
baseURL: flixHqBase,
|
||||
}
|
||||
);
|
||||
|
||||
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
|
||||
if (media.meta.type === MWMediaType.MOVIE) {
|
||||
const movie = v as FLIXMovie;
|
||||
return (
|
||||
compareTitle(movie.title, media.meta.title) &&
|
||||
movie.releaseDate === media.meta.year
|
||||
);
|
||||
}
|
||||
const serie = v as FLIXTVSerie;
|
||||
if (serie.seasons && media.meta.seasons) {
|
||||
return (
|
||||
compareTitle(serie.title, media.meta.title) &&
|
||||
serie.seasons === media.meta.seasons.length
|
||||
);
|
||||
}
|
||||
return compareTitle(serie.title, media.meta.title);
|
||||
if (v.type !== "Movie" && v.type !== "TV Series") return false;
|
||||
return (
|
||||
compareTitle(v.title, media.meta.title) &&
|
||||
flixTypeToMWType(v.type) === media.meta.type &&
|
||||
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<any>("/info", {
|
||||
const mediaInfo = await proxiedFetch<any>(`/info/${foundItem.id}`, {
|
||||
baseURL: flixHqBase,
|
||||
params: {
|
||||
id: flixId,
|
||||
type: flixTypeToMWType(foundItem.type),
|
||||
},
|
||||
});
|
||||
if (!mediaInfo.episodes) throw new Error("No watchable item found");
|
||||
if (!mediaInfo.id) throw new Error("No watchable item found");
|
||||
// get stream info from media
|
||||
progress(50);
|
||||
|
||||
let episodeId: string | undefined;
|
||||
if (media.meta.type === MWMediaType.MOVIE) {
|
||||
episodeId = mediaInfo.episodeId;
|
||||
} else if (media.meta.type === MWMediaType.SERIES) {
|
||||
const seasonNo = media.meta.seasonData.number;
|
||||
const episodeNo = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
const season = mediaInfo.seasons.find((o: any) => o.season === seasonNo);
|
||||
episodeId = season.episodes.find((o: any) => o.episode === episodeNo).id;
|
||||
}
|
||||
if (!episodeId) throw new Error("No watchable item found");
|
||||
progress(75);
|
||||
const watchInfo = await proxiedFetch<any>("/watch", {
|
||||
const watchInfo = await proxiedFetch<any>(`/watch/${episodeId}`, {
|
||||
baseURL: flixHqBase,
|
||||
params: {
|
||||
episodeId: mediaInfo.episodes[0].id,
|
||||
mediaId: flixId,
|
||||
id: mediaInfo.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
196
src/backend/providers/hdwatched.ts
Normal file
196
src/backend/providers/hdwatched.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { MWProviderContext } from "../helpers/provider";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
|
||||
const hdwatchedBase = "https://www.hdwatched.xyz";
|
||||
|
||||
const qualityMap: Record<number, MWStreamQuality> = {
|
||||
360: MWStreamQuality.Q360P,
|
||||
540: MWStreamQuality.Q540P,
|
||||
480: MWStreamQuality.Q480P,
|
||||
720: MWStreamQuality.Q720P,
|
||||
1080: MWStreamQuality.Q1080P,
|
||||
};
|
||||
|
||||
interface SearchRes {
|
||||
title: string;
|
||||
year?: number;
|
||||
href: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function getStreamFromEmbed(stream: string) {
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch stream");
|
||||
}
|
||||
|
||||
const streamSrc = source.getAttribute("src");
|
||||
const streamRes = source.getAttribute("res");
|
||||
|
||||
if (!streamSrc || !streamRes) throw new Error("Unable to find stream");
|
||||
|
||||
return {
|
||||
streamUrl: streamSrc,
|
||||
quality:
|
||||
streamRes && typeof +streamRes === "number"
|
||||
? qualityMap[+streamRes]
|
||||
: MWStreamQuality.QUNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMovie(targetSource: SearchRes) {
|
||||
const stream = await proxiedFetch<any>(`/embed/${targetSource.id}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch movie stream");
|
||||
}
|
||||
|
||||
return getStreamFromEmbed(stream);
|
||||
}
|
||||
|
||||
async function fetchSeries(
|
||||
targetSource: SearchRes,
|
||||
{ media, episode, progress }: MWProviderContext
|
||||
) {
|
||||
if (media.meta.type !== MWMediaType.SERIES)
|
||||
throw new Error("Media type mismatch");
|
||||
|
||||
const seasonNumber = media.meta.seasonData.number;
|
||||
const episodeNumber = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
if (!seasonNumber || !episodeNumber)
|
||||
throw new Error("Unable to get season or episode number");
|
||||
|
||||
const seriesPage = await proxiedFetch<any>(
|
||||
`${targetSource.href}?season=${media.meta.seasonData.number}`,
|
||||
{
|
||||
baseURL: hdwatchedBase,
|
||||
}
|
||||
);
|
||||
|
||||
const seasonPage = new DOMParser().parseFromString(seriesPage, "text/html");
|
||||
const pageElements = seasonPage.querySelectorAll("div.i-container");
|
||||
|
||||
const seriesList: SearchRes[] = [];
|
||||
pageElements.forEach((pageElement) => {
|
||||
const href = pageElement.querySelector("a")?.getAttribute("href") || "";
|
||||
const title =
|
||||
pageElement?.querySelector("span.content-title")?.textContent || "";
|
||||
|
||||
seriesList.push({
|
||||
title,
|
||||
href,
|
||||
id: href.split("/")[2], // Format: /free/{id}/{series-slug}-season-{season-number}-episode-{episode-number}
|
||||
});
|
||||
});
|
||||
|
||||
const targetEpisode = seriesList.find(
|
||||
(episodeEl) =>
|
||||
episodeEl.title.trim().toLowerCase() === `episode ${episodeNumber}`
|
||||
);
|
||||
|
||||
if (!targetEpisode) throw new Error("Unable to find episode");
|
||||
|
||||
progress(70);
|
||||
|
||||
const stream = await proxiedFetch<any>(`/embed/${targetEpisode.id}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch movie stream");
|
||||
}
|
||||
|
||||
return getStreamFromEmbed(stream);
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "hdwatched",
|
||||
displayName: "HDwatched",
|
||||
rank: 150,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
async scrape(options) {
|
||||
const { media, progress } = options;
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
|
||||
const search = await proxiedFetch<any>(`/search/${media.imdbId}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const searchPage = new DOMParser().parseFromString(search, "text/html");
|
||||
const pageElements = searchPage.querySelectorAll("div.i-container");
|
||||
|
||||
const searchList: SearchRes[] = [];
|
||||
pageElements.forEach((pageElement) => {
|
||||
const href = pageElement.querySelector("a")?.getAttribute("href") || "";
|
||||
const title =
|
||||
pageElement?.querySelector("span.content-title")?.textContent || "";
|
||||
const year =
|
||||
parseInt(
|
||||
pageElement
|
||||
?.querySelector("div.duration")
|
||||
?.textContent?.trim()
|
||||
?.split(" ")
|
||||
?.pop() || "",
|
||||
10
|
||||
) || 0;
|
||||
|
||||
searchList.push({
|
||||
title,
|
||||
year,
|
||||
href,
|
||||
id: href.split("/")[2], // Format: /free/{id}/{movie-slug} or /series/{id}/{series-slug}
|
||||
});
|
||||
});
|
||||
|
||||
progress(20);
|
||||
|
||||
const targetSource = searchList.find(
|
||||
(source) => source.year === (media.meta.year ? +media.meta.year : 0) // Compare year to make the search more robust
|
||||
);
|
||||
|
||||
if (!targetSource) {
|
||||
throw new Error("Could not find stream");
|
||||
}
|
||||
|
||||
progress(40);
|
||||
|
||||
if (media.meta.type === MWMediaType.SERIES) {
|
||||
const series = await fetchSeries(targetSource, options);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: series.streamUrl,
|
||||
quality: series.quality,
|
||||
type: MWStreamType.MP4,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const movie = await fetchMovie(targetSource);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: movie.streamUrl,
|
||||
quality: movie.quality,
|
||||
type: MWStreamType.MP4,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
@@ -22,6 +22,7 @@ registerProvider({
|
||||
displayName: "NetFilm",
|
||||
rank: 15,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
disabled: true, // The creator has asked us (very nicely) to leave him alone. Until (if) we self-host, netfilm should remain disabled
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
|
@@ -225,15 +225,21 @@ registerProvider({
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
const mappedCaptions = subtitleRes.list.map(
|
||||
(subtitle: any): MWCaption | null => {
|
||||
const sub = subtitle;
|
||||
sub.subtitles = subtitle.subtitles.filter((subFile: any) => {
|
||||
const extension = subFile.file_path.slice(-3);
|
||||
return [MWCaptionType.SRT, MWCaptionType.VTT].includes(extension);
|
||||
});
|
||||
return {
|
||||
needsProxy: true,
|
||||
langIso: subtitle.language,
|
||||
url: sub.subtitles[0].file_path,
|
||||
type: MWCaptionType.SRT,
|
||||
};
|
||||
}
|
||||
);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
|
29
src/components/CaptionColorSelector.tsx
Normal file
29
src/components/CaptionColorSelector.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useSettings } from "@/state/settings";
|
||||
import { Icon, Icons } from "./Icon";
|
||||
|
||||
export const colors = ["#ffffff", "#00ffff", "#ffff00"];
|
||||
export default function CaptionColorSelector({ color }: { color: string }) {
|
||||
const { captionSettings, setCaptionColor } = useSettings();
|
||||
return (
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded transition-[background-color,transform] duration-100 hover:bg-[#1c161b79] active:scale-110 ${
|
||||
color === captionSettings.style.color ? "bg-[#1C161B]" : ""
|
||||
}`}
|
||||
onClick={() => setCaptionColor(color)}
|
||||
>
|
||||
<div
|
||||
className="h-4 w-4 cursor-pointer appearance-none rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
<Icon
|
||||
className={[
|
||||
"absolute text-xs text-[#1C161B]",
|
||||
color === captionSettings.style.color ? "" : "hidden",
|
||||
].join(" ")}
|
||||
icon={Icons.CHECKMARK}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -37,7 +37,7 @@ export function Dropdown(props: DropdownProps) {
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute bottom-11 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:bottom-10 sm:text-sm">
|
||||
<Listbox.Options className="absolute top-10 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:top-10 sm:text-sm">
|
||||
{props.options.map((opt) => (
|
||||
<Listbox.Option
|
||||
className={({ active }) =>
|
||||
|
@@ -39,6 +39,8 @@ export enum Icons {
|
||||
GEAR = "gear",
|
||||
WATCH_PARTY = "watch_party",
|
||||
PICTURE_IN_PICTURE = "pictureInPicture",
|
||||
CHECKMARK = "checkmark",
|
||||
TACHOMETER = "tachometer",
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
@@ -85,6 +87,8 @@ const iconList: Record<Icons, string> = {
|
||||
gear: `<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="M481.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-30.9 28.1c-7.7 7.1-11.4 17.5-10.9 27.9c.1 2.9 .2 5.8 .2 8.8s-.1 5.9-.2 8.8c-.5 10.5 3.1 20.9 10.9 27.9l30.9 28.1c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-39.7-12.6c-10-3.2-20.8-1.1-29.7 4.6c-4.9 3.1-9.9 6.1-15.1 8.7c-9.3 4.8-16.5 13.2-18.8 23.4l-8.9 40.7c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-8.9-40.7c-2.2-10.2-9.5-18.6-18.8-23.4c-5.2-2.7-10.2-5.6-15.1-8.7c-8.8-5.7-19.7-7.8-29.7-4.6L69.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l30.9-28.1c7.7-7.1 11.4-17.5 10.9-27.9c-.1-2.9-.2-5.8-.2-8.8s.1-5.9 .2-8.8c.5-10.5-3.1-20.9-10.9-27.9L8.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l39.7 12.6c10 3.2 20.8 1.1 29.7-4.6c4.9-3.1 9.9-6.1 15.1-8.7c9.3-4.8 16.5-13.2 18.8-23.4l8.9-40.7c2-9.1 9-16.3 18.2-17.8C213.3 1.2 227.5 0 242 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l8.9 40.7c2.2 10.2 9.4 18.6 18.8 23.4c5.2 2.7 10.2 5.6 15.1 8.7c8.8 5.7 19.7 7.7 29.7 4.6l39.7-12.6c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM242 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z"/></svg>`,
|
||||
watch_party: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 448 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="M319.4 372c48.5-31.3 80.6-85.9 80.6-148c0-97.2-78.8-176-176-176S48 126.8 48 224c0 62.1 32.1 116.6 80.6 148c1.2 17.3 4 38 7.2 57.1l.2 1C56 395.8 0 316.5 0 224C0 100.3 100.3 0 224 0S448 100.3 448 224c0 92.5-56 171.9-136 206.1l.2-1.1c3.1-19.2 6-39.8 7.2-57zm-2.3-38.1c-1.6-5.7-3.9-11.1-7-16.2c-5.8-9.7-13.5-17-21.9-22.4c19.5-17.6 31.8-43 31.8-71.3c0-53-43-96-96-96s-96 43-96 96c0 28.3 12.3 53.8 31.8 71.3c-8.4 5.4-16.1 12.7-21.9 22.4c-3.1 5.1-5.4 10.5-7 16.2C99.8 307.5 80 268 80 224c0-79.5 64.5-144 144-144s144 64.5 144 144c0 44-19.8 83.5-50.9 109.9zM224 312c32.9 0 64 8.6 64 43.8c0 33-12.9 104.1-20.6 132.9c-5.1 19-24.5 23.4-43.4 23.4s-38.2-4.4-43.4-23.4c-7.8-28.5-20.6-99.7-20.6-132.8c0-35.1 31.1-43.8 64-43.8zm0-144a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></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>`,
|
||||
checkmark: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M9 22l-10-10.598 2.798-2.859 7.149 7.473 13.144-14.016 2.909 2.806z" /></svg>`,
|
||||
tachometer: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 576 512"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M128 288c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zm154.65-97.08l16.24-48.71c1.16-3.45 3.18-6.35 4.92-9.43-4.73-2.76-9.94-4.78-15.81-4.78-17.67 0-32 14.33-32 32 0 15.78 11.63 28.29 26.65 30.92zM176 176c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zM288 32C128.94 32 0 160.94 0 320c0 52.8 14.25 102.26 39.06 144.8 5.61 9.62 16.3 15.2 27.44 15.2h443c11.14 0 21.83-5.58 27.44-15.2C561.75 422.26 576 372.8 576 320c0-159.06-128.94-288-288-288zm212.27 400H75.73C57.56 397.63 48 359.12 48 320 48 187.66 155.66 80 288 80s240 107.66 240 240c0 39.12-9.56 77.63-27.73 112zM416 320c0 17.67 14.33 32 32 32s32-14.33 32-32-14.33-32-32-32-32 14.33-32 32zm-56.41-182.77c-12.72-4.23-26.16 2.62-30.38 15.17l-45.34 136.01C250.49 290.58 224 318.06 224 352c0 11.72 3.38 22.55 8.88 32h110.25c5.5-9.45 8.88-20.28 8.88-32 0-19.45-8.86-36.66-22.55-48.4l45.34-136.01c4.17-12.57-2.64-26.17-15.21-30.36zM432 208c0-15.8-11.66-28.33-26.72-30.93-.07.21-.07.43-.14.65l-19.5 58.49c4.37 2.24 9.11 3.8 14.36 3.8 17.67-.01 32-14.34 32-32.01z"/></svg>`,
|
||||
};
|
||||
|
||||
function ChromeCastButton() {
|
||||
|
47
src/components/Slider.tsx
Normal file
47
src/components/Slider.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ChangeEventHandler, useEffect, useRef } from "react";
|
||||
|
||||
export type SliderProps = {
|
||||
label?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value?: number;
|
||||
valueDisplay?: string;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
};
|
||||
|
||||
export function Slider(props: SliderProps) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const e = ref.current as HTMLInputElement;
|
||||
e.style.setProperty("--value", e.value);
|
||||
e.style.setProperty("--min", e.min === "" ? "0" : e.min);
|
||||
e.style.setProperty("--max", e.max === "" ? "100" : e.max);
|
||||
e.addEventListener("input", () => e.style.setProperty("--value", e.value));
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex flex-row gap-4">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{props.label ? (
|
||||
<label className="font-bold">{props.label}</label>
|
||||
) : null}
|
||||
<input
|
||||
type="range"
|
||||
ref={ref}
|
||||
className="styled-slider slider-progress mt-[20px]"
|
||||
onChange={props.onChange}
|
||||
value={props.value}
|
||||
max={props.max}
|
||||
min={props.min}
|
||||
step={props.step}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 aspect-[2/1] h-8 rounded-sm bg-[#1C161B] pt-1">
|
||||
<div className="text-center font-bold text-white">
|
||||
{props.valueDisplay ?? props.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -35,9 +35,14 @@ export function Modal(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalCard(props: { children?: ReactNode }) {
|
||||
export function ModalCard(props: { className?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="relative mx-2 max-w-[600px] overflow-hidden rounded-lg bg-denim-200 px-10 py-10">
|
||||
<div
|
||||
className={[
|
||||
"relative mx-2 w-[500px] overflow-hidden rounded-lg bg-denim-300 px-10 py-10 sm:w-[500px] md:w-[500px] lg:w-[1000px]",
|
||||
props.className ?? "",
|
||||
].join(" ")}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useState } from "react";
|
||||
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 SettingsModal from "@/views/SettingsModal";
|
||||
import { BrandPill } from "./BrandPill";
|
||||
|
||||
export interface NavigationProps {
|
||||
@@ -13,7 +14,7 @@ export interface NavigationProps {
|
||||
|
||||
export function Navigation(props: NavigationProps) {
|
||||
const bannerHeight = useBannerSize();
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
return (
|
||||
<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"
|
||||
@@ -42,6 +43,14 @@ export function Navigation(props: NavigationProps) {
|
||||
props.children ? "hidden sm:flex" : "flex"
|
||||
} relative flex-row gap-4`}
|
||||
>
|
||||
<IconPatch
|
||||
className="text-2xl text-white"
|
||||
icon={Icons.GEAR}
|
||||
clickable
|
||||
onClick={() => {
|
||||
setShowModal(true);
|
||||
}}
|
||||
/>
|
||||
<a
|
||||
href={conf().DISCORD_LINK}
|
||||
target="_blank"
|
||||
@@ -60,6 +69,7 @@ export function Navigation(props: NavigationProps) {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsModal show={showModal} onClose={() => setShowModal(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -33,6 +33,9 @@ function MediaCardContent({
|
||||
|
||||
const canLink = linkable && !closable;
|
||||
|
||||
const dotListContent = [t(`media.${media.type}`)];
|
||||
if (media.year) dotListContent.push(media.year);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
|
||||
@@ -115,10 +118,7 @@ function MediaCardContent({
|
||||
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
|
||||
<span>{media.title}</span>
|
||||
</h1>
|
||||
<DotList
|
||||
className="text-xs"
|
||||
content={[t(`media.${media.type}`), media.year]}
|
||||
/>
|
||||
<DotList className="text-xs" content={dotListContent} />
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition";
|
||||
import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
@@ -133,13 +134,15 @@ export const FloatingCardView = {
|
||||
action?: React.ReactNode;
|
||||
backText?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
let left = (
|
||||
<div
|
||||
onClick={props.goBack}
|
||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
||||
>
|
||||
<Icon icon={Icons.ARROW_LEFT} />
|
||||
<span>{props.backText || "Go back"}</span>
|
||||
<span>{props.backText || t("videoPlayer.popouts.back")}</span>
|
||||
</div>
|
||||
);
|
||||
if (props.close)
|
||||
|
@@ -1,5 +1,11 @@
|
||||
import { Transition } from "@/components/Transition";
|
||||
import React, { ReactNode, useCallback, useEffect, useRef } from "react";
|
||||
import React, {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface Props {
|
||||
@@ -10,6 +16,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function FloatingContainer(props: Props) {
|
||||
const [portalElement, setPortalElement] = useState<Element | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const target = useRef<Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -34,23 +42,34 @@ export function FloatingContainer(props: Props) {
|
||||
[props]
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<Transition show={props.show} animation="none">
|
||||
<div className="popout-wrapper pointer-events-auto fixed inset-0 z-[999] select-none">
|
||||
<Transition animation="fade" isChild>
|
||||
<div
|
||||
onClick={click}
|
||||
className={[
|
||||
"absolute inset-0",
|
||||
props.darken ? "bg-black opacity-90" : "",
|
||||
].join(" ")}
|
||||
/>
|
||||
</Transition>
|
||||
<Transition animation="slide-up" className="h-0" isChild>
|
||||
{props.children}
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>,
|
||||
document.body
|
||||
useEffect(() => {
|
||||
const element = ref.current?.closest(".popout-location");
|
||||
setPortalElement(element ?? document.body);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{portalElement
|
||||
? createPortal(
|
||||
<Transition show={props.show} animation="none">
|
||||
<div className="popout-wrapper pointer-events-auto fixed inset-0 z-[999] select-none">
|
||||
<Transition animation="fade" isChild>
|
||||
<div
|
||||
onClick={click}
|
||||
className={[
|
||||
"absolute inset-0",
|
||||
props.darken ? "bg-black opacity-90" : "",
|
||||
].join(" ")}
|
||||
/>
|
||||
</Transition>
|
||||
<Transition animation="slide-up" className="h-0" isChild>
|
||||
{props.children}
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>,
|
||||
portalElement
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -29,6 +29,7 @@ export function FloatingView(props: Props) {
|
||||
data-floating-page={props.show ? "true" : undefined}
|
||||
style={{
|
||||
height: props.height ? `${props.height}px` : undefined,
|
||||
maxHeight: "70vh",
|
||||
width: props.width ? width : undefined,
|
||||
}}
|
||||
>
|
||||
|
@@ -21,8 +21,20 @@ export function FloatingCardMobilePosition(props: MobilePositionProps) {
|
||||
}));
|
||||
|
||||
const bind = useDrag(
|
||||
({ last, velocity: [, vy], direction: [, dy], movement: [, my] }) => {
|
||||
({
|
||||
last,
|
||||
velocity: [, vy],
|
||||
direction: [, dy],
|
||||
movement: [, my],
|
||||
...event
|
||||
}) => {
|
||||
if (closing.current) return;
|
||||
|
||||
const isInScrollable = (event.target as HTMLDivElement).closest(
|
||||
".overflow-y-auto"
|
||||
);
|
||||
if (isInScrollable) return; // Don't attempt to swipe the thing away if it's a scroll area unless the scroll area is at the top and the user is swiping down
|
||||
|
||||
const height = cardRect?.height ?? 0;
|
||||
if (last) {
|
||||
// if past half height downwards
|
||||
@@ -69,7 +81,7 @@ export function FloatingCardMobilePosition(props: MobilePositionProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none"
|
||||
className="is-mobile-view absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none"
|
||||
style={{
|
||||
transform: `translateY(${
|
||||
window.innerHeight - (cardRect?.height ?? 0) + 200
|
||||
|
@@ -6,7 +6,7 @@ function getInitialValue(params: { type: string; query: string }) {
|
||||
const type =
|
||||
Object.values(MWMediaType).find((v) => params.type === v) ||
|
||||
MWMediaType.MOVIE;
|
||||
const searchQuery = params.query || "";
|
||||
const searchQuery = decodeURIComponent(params.query || "");
|
||||
return { type, searchQuery };
|
||||
}
|
||||
|
||||
|
@@ -1,12 +1,15 @@
|
||||
import React, { ReactNode, Suspense } from "react";
|
||||
import "core-js/stable";
|
||||
import React, { Suspense } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { BrowserRouter, HashRouter } from "react-router-dom";
|
||||
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
|
||||
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/sentry";
|
||||
import "@/setup/i18n";
|
||||
import "@/setup/index.css";
|
||||
import "@/backend";
|
||||
@@ -21,9 +24,7 @@ if (key) {
|
||||
}
|
||||
initializeChromecast();
|
||||
registerSW({
|
||||
onNeedRefresh() {
|
||||
window.location.reload();
|
||||
},
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const LazyLoadedApp = React.lazy(async () => {
|
||||
|
@@ -1,62 +1,91 @@
|
||||
import { lazy } from "react";
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
import { BookmarkContextProvider } from "@/state/bookmark";
|
||||
import { WatchedContextProvider } from "@/state/watched";
|
||||
import { SettingsProvider } from "@/state/settings";
|
||||
|
||||
import { NotFoundPage } from "@/views/notfound/NotFoundView";
|
||||
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";
|
||||
import { TestView } from "@/views/developer/TestView";
|
||||
|
||||
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>
|
||||
<SettingsProvider>
|
||||
<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}
|
||||
/>
|
||||
{/* 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}
|
||||
/>
|
||||
|
||||
{/* other */}
|
||||
<Route exact path="/dev" component={DeveloperView} />
|
||||
<Route exact path="/dev/test" component={TestView} />
|
||||
<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>
|
||||
{/* other */}
|
||||
{process.env.NODE_ENV === "development" ? (
|
||||
<>
|
||||
<Route
|
||||
exact
|
||||
path="/dev"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/DeveloperView")
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/dev/test"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/TestView")
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/dev/video"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/VideoTesterView")
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/dev/providers"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/ProviderTesterView")
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/dev/embeds"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/EmbedTesterView")
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Route path="*" component={NotFoundPage} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
</BannerContextProvider>
|
||||
</BookmarkContextProvider>
|
||||
</WatchedContextProvider>
|
||||
</SettingsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -1,4 +1,6 @@
|
||||
export const APP_VERSION = import.meta.env.PACKAGE_VERSION;
|
||||
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
|
||||
export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
|
||||
export const APP_VERSION = "3.0.6";
|
||||
export const GA_ID = "G-44YVXRL61C";
|
||||
export const SENTRY_DSN =
|
||||
"https://b267ab7d52674c23af4e4e6cf2956251@o4505053491167232.ingest.sentry.io/4505053495296000";
|
||||
|
@@ -4,7 +4,17 @@ import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
// Languages
|
||||
import en from "./locales/en/translation.json";
|
||||
import nl from "./locales/nl/translation.json";
|
||||
import { captionLanguages } from "./iso6391";
|
||||
|
||||
const locales = {
|
||||
en: {
|
||||
translation: en,
|
||||
},
|
||||
nl: {
|
||||
translation: nl,
|
||||
},
|
||||
};
|
||||
i18n
|
||||
// detect user language
|
||||
// learn more: https://github.com/i18next/i18next-browser-languageDetector
|
||||
@@ -15,16 +25,14 @@ i18n
|
||||
// for all options read: https://www.i18next.com/overview/configuration-options
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
|
||||
resources: {
|
||||
en: {
|
||||
translation: en,
|
||||
},
|
||||
},
|
||||
|
||||
resources: locales,
|
||||
interpolation: {
|
||||
escapeValue: false, // not needed for react as it escapes by default
|
||||
},
|
||||
});
|
||||
|
||||
export const appLanguageOptions = captionLanguages.filter((x) => {
|
||||
return Object.keys(locales).includes(x.id);
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
@@ -38,6 +38,7 @@ body[data-no-select] {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
@@ -54,3 +55,127 @@ body[data-no-select] {
|
||||
.google-cast-button:not(.casting) google-cast-launcher {
|
||||
@apply brightness-[500];
|
||||
}
|
||||
|
||||
.is-mobile-view .overflow-y-auto {
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
/*generated with Input range slider CSS style generator (version 20211225)
|
||||
https://toughengineer.github.io/demo/slider-styler*/
|
||||
:root {
|
||||
--slider-height: 0.25rem;
|
||||
--slider-border-radius: 1em;
|
||||
--slider-progress-background: #8652bb;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider {
|
||||
height: var(--slider-height);
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: var(--slider-border-radius);
|
||||
background: #1C161B;
|
||||
}
|
||||
|
||||
/*progress support*/
|
||||
input[type=range].styled-slider.slider-progress {
|
||||
--range: calc(var(--max) - var(--min));
|
||||
--ratio: calc((var(--value) - var(--min)) / var(--range));
|
||||
--sx: calc(0.5 * 1rem + var(--ratio) * (100% - 1rem));
|
||||
}
|
||||
|
||||
/*webkit*/
|
||||
input[type=range].styled-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: var(--slider-border-radius);
|
||||
background: #FFFFFF;
|
||||
border: none;
|
||||
box-shadow: 0 0 2px #000000;
|
||||
margin-top: calc(0.25em * 0.5 - 1rem * 0.5);
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-webkit-slider-runnable-track {
|
||||
height: var(--slider-height);
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: var(--slider-border-radius);
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-webkit-slider-thumb:hover {
|
||||
background: #DCDCDC;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider.slider-progress::-webkit-slider-runnable-track {
|
||||
background: linear-gradient(var(--slider-progress-background), var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
|
||||
}
|
||||
|
||||
/*mozilla*/
|
||||
input[type=range].styled-slider::-moz-range-thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: var(--slider-border-radius);
|
||||
background: #FFFFFF;
|
||||
border: none;
|
||||
box-shadow: 0 0 2px #000000;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-moz-range-track {
|
||||
height: var(--slider-height);
|
||||
border: none;
|
||||
border-radius: var(--slider-border-radius);
|
||||
background: #1C161B;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-moz-range-thumb:hover {
|
||||
background: #DCDCDC;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider.slider-progress::-moz-range-track {
|
||||
background: linear-gradient(var(--slider-progress-background), var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
|
||||
}
|
||||
|
||||
/*ms*/
|
||||
input[type=range].styled-slider::-ms-fill-upper {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-ms-fill-lower {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-ms-thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: var(--slider-border-radius);
|
||||
background: #FFFFFF;
|
||||
border: none;
|
||||
box-shadow: 0 0 2px #000000;
|
||||
margin-top: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-ms-track {
|
||||
height: var(--slider-height);
|
||||
border-radius: var(--slider-border-radius);
|
||||
background: #1C161B;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-ms-thumb:hover {
|
||||
background: #DCDCDC;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider.slider-progress::-ms-fill-lower {
|
||||
height: var(--slider-height);
|
||||
border-radius: var(--slider-border-radius) 0 0 5px;
|
||||
margin: -undefined 0 -undefined -undefined;
|
||||
background: var(--slider-progress-background);
|
||||
border: none;
|
||||
border-right-width: 0;
|
||||
}
|
1326
src/setup/iso6391.ts
Normal file
1326
src/setup/iso6391.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,24 +57,38 @@
|
||||
"backToHome": "Back to home",
|
||||
"backToHomeShort": "Back",
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
||||
"timeLeft": "{{timeLeft}} left",
|
||||
"finishAt": "Finish at {{timeFinished}}",
|
||||
"buttons": {
|
||||
"episodes": "Episodes",
|
||||
"source": "Source",
|
||||
"captions": "Captions",
|
||||
"download": "Download",
|
||||
"settings": "Settings",
|
||||
"pictureInPicture": "Picture in Picture"
|
||||
"pictureInPicture": "Picture in Picture",
|
||||
"playbackSpeed": "Playback speed"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "Go back",
|
||||
"sources": "Sources",
|
||||
"seasons": "Seasons",
|
||||
"captions": "Captions",
|
||||
"playbackSpeed": "Playback speed",
|
||||
"customPlaybackSpeed": "Custom playback speed",
|
||||
"captionPreferences": {
|
||||
"title": "Customize",
|
||||
"delay": "Delay",
|
||||
"fontSize": "Size",
|
||||
"opacity": "Opacity",
|
||||
"color": "Color"
|
||||
},
|
||||
"episode": "E{{index}} - {{title}}",
|
||||
"noCaptions": "No captions",
|
||||
"linkedCaptions": "Linked captions",
|
||||
"customCaption": "Custom caption",
|
||||
"uploadCustomCaption": "Upload caption (SRT, VTT)",
|
||||
"uploadCustomCaption": "Upload caption",
|
||||
"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"
|
||||
@@ -84,13 +98,20 @@
|
||||
"embeds": "Choose which video to view",
|
||||
"seasons": "Choose which season you want to watch",
|
||||
"episode": "Pick an episode",
|
||||
"captions": "Choose a subtitle language"
|
||||
"captions": "Choose a subtitle language",
|
||||
"captionPreferences": "Make subtitles look how you want it",
|
||||
"playbackSpeed": "Change the playback speed"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "The video player encounted a fatal error, please report it to the <0>Discord server</0> or on <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"captionLanguage": "Caption Language"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "New version now released!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
|
100
src/setup/locales/fr/translation.json
Normal file
100
src/setup/locales/fr/translation.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Recherche de votre série préférée...",
|
||||
"loading_movie": "Recherche de vos films préférés...",
|
||||
"loading": "Chargement...",
|
||||
"allResults": "C'est tout ce que nous avons!",
|
||||
"noResults": "Nous n'avons rien trouvé!",
|
||||
"allFailed": "Le média n'a pas été trouvé, veuillez réessayez!",
|
||||
"headingTitle": "Résultats de la recherche",
|
||||
"bookmarks": "Favoris",
|
||||
"continueWatching": "Continuer le visionnage",
|
||||
"title": "Que voulez-vous voir?",
|
||||
"placeholder": "Que voulez-vous voir?"
|
||||
},
|
||||
"media": {
|
||||
"title": "Impossible de trouver ce média",
|
||||
"description": "Nous n'avons pas pu trouver le média que vous avez demandé. Soit il a été supprimé, soit vous avez altéré l'URL."
|
||||
},
|
||||
"provider": {
|
||||
"title": "Ce fournisseur a été désactivé",
|
||||
"description": "Nous avons eu des problèmes avec le fournisseur ou bien il était trop instable pour être utilisé, donc nous avons dû le désactiver."
|
||||
},
|
||||
"page": {
|
||||
"title": "Impossible de trouver cette page",
|
||||
"description": "Nous avons cherché partout : sous les poubelles, dans le placard, derrière le proxy, mais nous n'avons finalement pas pu trouver la page que vous recherchez."
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "Film",
|
||||
"series": "Série",
|
||||
"Search": "Rechercher"
|
||||
},
|
||||
"videoPlayer": {
|
||||
"findingBestVideo": "Recherche de la meilleure vidéo pour vous",
|
||||
"noVideos": "Désolé, nous n'avons pas pu trouver de vidéos pour vous",
|
||||
"loading": "Chargement...",
|
||||
"backToHome": "Retour à la page d'accueil",
|
||||
"backToHomeShort": "Retour",
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
||||
"timeLeft": "{{timeLeft}} restant",
|
||||
"finishAt": "Terminer à {{timeFinished}}",
|
||||
"buttons": {
|
||||
"episodes": "Épisodes",
|
||||
"source": "Source",
|
||||
"captions": "Sous-titres",
|
||||
"download": "Télécharger",
|
||||
"settings": "Paramètres",
|
||||
"pictureInPicture": "Image dans l'image",
|
||||
"playbackSpeed": "Vitesse"
|
||||
},
|
||||
"popouts": {
|
||||
"sources": "Sources",
|
||||
"seasons": "Saisons",
|
||||
"captions": "Sous-titres",
|
||||
"captionPreferences": {
|
||||
"title": "Personnaliser",
|
||||
"delay": "Délai",
|
||||
"fontSize": "Taille",
|
||||
"opacity": "Opacité",
|
||||
"color": "Couleur"
|
||||
},
|
||||
"episode": "E{{index}} - {{title}}",
|
||||
"noCaptions": "Pas de sous-titres",
|
||||
"linkedCaptions": "Sous-titres liés",
|
||||
"customCaption": "Sous-titres personnalisés",
|
||||
"uploadCustomCaption": "Télécharger des sous-titres",
|
||||
"noEmbeds": "Aucun contenu intégré n'a été trouvé pour cette source",
|
||||
"errors": {
|
||||
"loadingWentWong": "Un problème est survenu lors du chargement des épisodes pour {{seasonTitle}}",
|
||||
"embedsError": "Un problème est survenu lors du chargement des contenus intégrés pour cet élément que vous aimez"
|
||||
},
|
||||
"descriptions": {
|
||||
"sources": "Quel fournisseur voulez-vous utiliser ?",
|
||||
"embeds": "Choisissez quelle vidéo regarder",
|
||||
"seasons": "Choisissez la saison que vous voulez regarder",
|
||||
"episode": "Sélectionnez un épisode",
|
||||
"captions": "Choisissez une langue de sous-titres",
|
||||
"captionPreferences": "Personnalisez l'apparence des sous-titres"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "Le lecteur vidéo a rencontré une erreur fatale, veuillez la signaler au serveur <0>Discord</0> ou sur <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "Nouvelle version disponible!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
"newDomainText": "movie-web déménagera bientôt vers un nouveau domaine : <0>https://movie-web.app</0>. Veillez à mettre à jour tous vos favoris car <1>l'ancien site web cessera de fonctionner le {{date}}.</1>",
|
||||
"tireless": "Nous avons travaillé sans relâche sur cette nouvelle mise à jour et nous espérons que vous apprécierez ce que nous avons préparé ces derniers mois.",
|
||||
"leaveAnnouncement": "Emmenez-moi là!"
|
||||
},
|
||||
"casting": {
|
||||
"casting": "Transmission à l'appareil..."
|
||||
},
|
||||
"errors": {
|
||||
"offline": "Vérifiez votre connexion internet"
|
||||
}
|
||||
}
|
128
src/setup/locales/nl/translation.json
Normal file
128
src/setup/locales/nl/translation.json
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "We zoeken je favoriete series...",
|
||||
"loading_movie": "We zoeken je favoriete films...",
|
||||
"loading": "Aan het zoeken...",
|
||||
"allResults": "Dat is het!",
|
||||
"noResults": "We konden helaas niets vinden.",
|
||||
"allFailed": "Het is niet gelukt de media te laden, probeer het nog eens.",
|
||||
"headingTitle": "Zoekresultaten",
|
||||
"bookmarks": "Opgeslagen",
|
||||
"continueWatching": "Kijk verder",
|
||||
"title": "Wat wil je graag kijken?",
|
||||
"placeholder": "Wat wil je graag kijken?"
|
||||
},
|
||||
"media": {
|
||||
"movie": "Film",
|
||||
"series": "Serie",
|
||||
"stopEditing": "Stop met bewerken",
|
||||
"errors": {
|
||||
"genericTitle": "Oeps, hier ging iets mis!",
|
||||
"failedMeta": "Het is niet gelukt de meta-informatie op te halen/",
|
||||
"mediaFailed": "Het is niet gelukt deze media op te halen. Controleer of je een internetverbinding hebt en probeer het nog een keer.",
|
||||
"videoFailed": "Er ging iets mis tijdens het spelen van deze video. Als dit blijft gebeuren, deel het dan in de <0>Discord server</0> of maak een <1>GitHub issue</1>."
|
||||
}
|
||||
},
|
||||
"seasons": {
|
||||
"seasonAndEpisode": "S{{season}} A{{episode}}"
|
||||
},
|
||||
"notFound": {
|
||||
"genericTitle": "Pagina niet gevonden",
|
||||
"backArrow": "Naar de home-pagina",
|
||||
"media": {
|
||||
"title": "We konden deze media niet vinden.",
|
||||
"description": "We konden dit stukje media niet vinden. Het is mogelijk verwijderd, of jij hebt zelf de URL aangepast."
|
||||
},
|
||||
"provider": {
|
||||
"title": "Deze bron is niet langer beschikbaar",
|
||||
"description": "Deze bron was helaas te instabiel, we hebben hem jammer genoeg uit moeten zetten."
|
||||
},
|
||||
"page": {
|
||||
"title": "Pagina niet gevonden",
|
||||
"description": "We hebben echt alles geprobeerd, zelfs tijdrijzen; echter hebben we deze pagina helaas niet kunnen vinden."
|
||||
}
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "Films",
|
||||
"series": "Series",
|
||||
"Search": "Zoeken"
|
||||
},
|
||||
"videoPlayer": {
|
||||
"findingBestVideo": "De beste video voor jou aan het zoeken...",
|
||||
"noVideos": "Helaas konden we dat filmpje niet vinden",
|
||||
"loading": "Aan het laden...",
|
||||
"backToHome": "Naar de home-pagina",
|
||||
"backToHomeShort": "Terug",
|
||||
"seasonAndEpisode": "S{{season}} A{{episode}}",
|
||||
"timeLeft": "Nog {{timeLeft}}",
|
||||
"finishAt": "Afgelopen om {{timeFinished}}",
|
||||
"buttons": {
|
||||
"episodes": "Afleveringen",
|
||||
"source": "Bron",
|
||||
"captions": "Ondertiteling",
|
||||
"download": "Download",
|
||||
"settings": "Instellingen",
|
||||
"pictureInPicture": "Beeld-in-beeld",
|
||||
"playbackSpeed": "Afspeelsnelheid"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "Terug",
|
||||
"sources": "Bronnen",
|
||||
"seasons": "Seizoenen",
|
||||
"captions": "Ondertiteling",
|
||||
"playbackSpeed": "Afspeelsnelheid",
|
||||
"customPlaybackSpeed": "Andere snelheden",
|
||||
"captionPreferences": {
|
||||
"title": "Instellingen",
|
||||
"delay": "Vertraging",
|
||||
"fontSize": "Lettergrootte",
|
||||
"opacity": "Doorzichtbaarheid",
|
||||
"color": "Kleur"
|
||||
},
|
||||
"episode": "A{{index}} - {{title}}",
|
||||
"noCaptions": "Geen ondertiteling",
|
||||
"linkedCaptions": "Gelinkte ondertiteling",
|
||||
"customCaption": "Eigen ondertiteling",
|
||||
"uploadCustomCaption": "Ondertiteling uploaden",
|
||||
"noEmbeds": "We hebben geen filmpjes kunnen vinden voor deze bron.",
|
||||
|
||||
"errors": {
|
||||
"loadingWentWong": "Er ging iets mis tijdens het laden van de afleveringen voor {{seasonTitle}}",
|
||||
"embedsError": "Er ging iets mis tijdens het laden van de embeds voor dit dingetje dat je waarschijnlijk leuk vindt"
|
||||
},
|
||||
"descriptions": {
|
||||
"sources": "Welke bron wil je graag gebruiken",
|
||||
"embeds": "Welk filmpje wil je gebruiken?",
|
||||
"seasons": "Welk seizoen wil je kijken?",
|
||||
"episode": "Kies een aflevering",
|
||||
"captions": "Kies een taal voor de ondertiteling",
|
||||
"captionPreferences": "Pas de ondertiteling aan aan je voorkeuren",
|
||||
"playbackSpeed": "Pas de afspeelsnelhijd aan"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "De videospeler is helaas ontploft, rapporteer deze fout op de <0>Discord server</0> of op <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Instellingen",
|
||||
"language": "Taal",
|
||||
"captionLanguage": "Taal voor de Ondertiteling"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "De nieuwe versie is uit!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
"newDomainText": "We gaan binnenkort verhuizen naar een nieuw domein: <0>https://movie-web.app</0>. Pas je bladwijzers aan naar het nieuwe domein, want </b>het oude domein gaat stoppen met werken op {{date}}.</b>",
|
||||
"tireless": "We hebben mega hard gewerkt aan deze nieuwe versie, dus we hopen dat je er van gaat genieten.",
|
||||
"leaveAnnouncement": "Let's go!"
|
||||
},
|
||||
"casting": {
|
||||
"casting": "Aan het casten..."
|
||||
},
|
||||
"errors": {
|
||||
"offline": "Controleer je internetverbinding"
|
||||
}
|
||||
}
|
15
src/setup/sentry.tsx
Normal file
15
src/setup/sentry.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { CaptureConsole, HttpClient } from "@sentry/integrations";
|
||||
import { SENTRY_DSN } from "@/setup/constants";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
release: `movie-web@${conf().APP_VERSION}`,
|
||||
sampleRate: 0.5,
|
||||
integrations: [
|
||||
new Sentry.BrowserTracing(),
|
||||
new CaptureConsole(),
|
||||
new HttpClient(),
|
||||
],
|
||||
});
|
90
src/state/settings/context.tsx
Normal file
90
src/state/settings/context.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useStore } from "@/utils/storage";
|
||||
import { createContext, ReactNode, useContext, useMemo } from "react";
|
||||
import { LangCode } from "@/setup/iso6391";
|
||||
import { SettingsStore } from "./store";
|
||||
import { MWSettingsData } from "./types";
|
||||
|
||||
interface MWSettingsDataSetters {
|
||||
setLanguage(language: LangCode): void;
|
||||
setCaptionLanguage(language: LangCode): void;
|
||||
setCaptionDelay(delay: number): void;
|
||||
setCaptionColor(color: string): void;
|
||||
setCaptionFontSize(size: number): void;
|
||||
setCaptionBackgroundColor(backgroundColor: number): void;
|
||||
}
|
||||
type MWSettingsDataWrapper = MWSettingsData & MWSettingsDataSetters;
|
||||
const SettingsContext = createContext<MWSettingsDataWrapper>(null as any);
|
||||
export function SettingsProvider(props: { children: ReactNode }) {
|
||||
function enforceRange(min: number, value: number, max: number) {
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
const [settings, setSettings] = useStore(SettingsStore);
|
||||
const context: MWSettingsDataWrapper = useMemo(() => {
|
||||
const settingsContext: MWSettingsDataWrapper = {
|
||||
...settings,
|
||||
setLanguage(language) {
|
||||
setSettings((oldSettings) => {
|
||||
return {
|
||||
...oldSettings,
|
||||
language,
|
||||
};
|
||||
});
|
||||
},
|
||||
setCaptionLanguage(language) {
|
||||
setSettings((oldSettings) => {
|
||||
const captionSettings = oldSettings.captionSettings;
|
||||
captionSettings.language = language;
|
||||
const newSettings = oldSettings;
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setCaptionDelay(delay: number) {
|
||||
setSettings((oldSettings) => {
|
||||
const captionSettings = oldSettings.captionSettings;
|
||||
captionSettings.delay = enforceRange(-10, delay, 10);
|
||||
const newSettings = oldSettings;
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setCaptionColor(color) {
|
||||
setSettings((oldSettings) => {
|
||||
const style = oldSettings.captionSettings.style;
|
||||
style.color = color;
|
||||
const newSettings = oldSettings;
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setCaptionFontSize(size) {
|
||||
setSettings((oldSettings) => {
|
||||
const style = oldSettings.captionSettings.style;
|
||||
style.fontSize = enforceRange(10, size, 60);
|
||||
const newSettings = oldSettings;
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setCaptionBackgroundColor(backgroundColor) {
|
||||
setSettings((oldSettings) => {
|
||||
const style = oldSettings.captionSettings.style;
|
||||
style.backgroundColor = `${style.backgroundColor.substring(
|
||||
0,
|
||||
7
|
||||
)}${backgroundColor.toString(16).padStart(2, "0")}`;
|
||||
const newSettings = oldSettings;
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
};
|
||||
return settingsContext;
|
||||
}, [settings, setSettings]);
|
||||
return (
|
||||
<SettingsContext.Provider value={context}>
|
||||
{props.children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
return useContext(SettingsContext);
|
||||
}
|
||||
|
||||
export default SettingsContext;
|
1
src/state/settings/index.ts
Normal file
1
src/state/settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./context";
|
48
src/state/settings/store.ts
Normal file
48
src/state/settings/store.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createVersionedStore } from "@/utils/storage";
|
||||
import { MWSettingsData, MWSettingsDataV1 } from "./types";
|
||||
|
||||
export const SettingsStore = createVersionedStore<MWSettingsData>()
|
||||
.setKey("mw-settings")
|
||||
.addVersion({
|
||||
version: 0,
|
||||
create(): MWSettingsDataV1 {
|
||||
return {
|
||||
language: "en",
|
||||
captionSettings: {
|
||||
delay: 0,
|
||||
style: {
|
||||
color: "#ffffff",
|
||||
fontSize: 25,
|
||||
backgroundColor: "#00000096",
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
migrate(data: MWSettingsDataV1): MWSettingsData {
|
||||
return {
|
||||
language: data.language,
|
||||
captionSettings: {
|
||||
language: "none",
|
||||
...data.captionSettings,
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
.addVersion({
|
||||
version: 1,
|
||||
create(): MWSettingsData {
|
||||
return {
|
||||
language: "en",
|
||||
captionSettings: {
|
||||
delay: 0,
|
||||
language: "none",
|
||||
style: {
|
||||
color: "#ffffff",
|
||||
fontSize: 25,
|
||||
backgroundColor: "#00000096",
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
.build();
|
36
src/state/settings/types.ts
Normal file
36
src/state/settings/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { LangCode } from "@/setup/iso6391";
|
||||
|
||||
export interface CaptionStyleSettings {
|
||||
color: string;
|
||||
/**
|
||||
* Range is [10, 30]
|
||||
*/
|
||||
fontSize: number;
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
export interface CaptionSettingsV1 {
|
||||
/**
|
||||
* Range is [-10, 10]s
|
||||
*/
|
||||
delay: number;
|
||||
style: CaptionStyleSettings;
|
||||
}
|
||||
|
||||
export interface CaptionSettings {
|
||||
language: LangCode;
|
||||
/**
|
||||
* Range is [-10, 10]s
|
||||
*/
|
||||
delay: number;
|
||||
style: CaptionStyleSettings;
|
||||
}
|
||||
export interface MWSettingsDataV1 {
|
||||
language: LangCode;
|
||||
captionSettings: CaptionSettingsV1;
|
||||
}
|
||||
|
||||
export interface MWSettingsData {
|
||||
language: LangCode;
|
||||
captionSettings: CaptionSettings;
|
||||
}
|
@@ -99,7 +99,7 @@ function buildStorageObject<T>(store: InternalStoreData): StoreRet<T> {
|
||||
localStorage.setItem(key, JSON.stringify(withVersion));
|
||||
|
||||
if (!storeCallbacks[key]) storeCallbacks[key] = [];
|
||||
storeCallbacks[key].forEach((v) => v(structuredClone(data)));
|
||||
storeCallbacks[key].forEach((v) => v(window.structuredClone(data)));
|
||||
}
|
||||
|
||||
return {
|
||||
|
@@ -27,9 +27,11 @@ 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 { PictureInPictureAction } from "@/video/components/actions/PictureInPictureAction";
|
||||
import { CaptionRendererAction } from "./actions/CaptionRendererAction";
|
||||
import { SettingsAction } from "./actions/SettingsAction";
|
||||
import { DividerAction } from "./actions/DividerAction";
|
||||
import { PictureInPictureAction } from "./actions/PictureInPictureAction";
|
||||
import { VolumeAdjustedAction } from "./actions/VolumeAdjustedAction";
|
||||
|
||||
type Props = VideoPlayerBaseProps;
|
||||
|
||||
@@ -90,6 +92,7 @@ export function VideoPlayer(props: Props) {
|
||||
<>
|
||||
<KeyboardShortcutsAction />
|
||||
<PageTitleAction />
|
||||
<VolumeAdjustedAction />
|
||||
<VideoPlayerError onGoBack={props.onGoBack}>
|
||||
<BackdropAction onBackdropChange={onBackdropChange}>
|
||||
<CenterPosition>
|
||||
@@ -165,6 +168,7 @@ export function VideoPlayer(props: Props) {
|
||||
</Transition>
|
||||
{show ? <PopoutProviderAction /> : null}
|
||||
</BackdropAction>
|
||||
<CaptionRendererAction isControlsShown={show} />
|
||||
{props.children}
|
||||
</VideoPlayerError>
|
||||
</>
|
||||
|
@@ -39,7 +39,7 @@ function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) {
|
||||
<div
|
||||
ref={ref}
|
||||
className={[
|
||||
"is-video-player relative h-full w-full select-none overflow-hidden bg-black",
|
||||
"is-video-player popout-location relative h-full w-full select-none overflow-hidden bg-black",
|
||||
props.includeSafeArea || videoInterface.isFullscreen
|
||||
? "[border-left:env(safe-area-inset-left)_solid_transparent] [border-right:env(safe-area-inset-right)_solid_transparent]"
|
||||
: "",
|
||||
|
@@ -24,18 +24,16 @@ export function BackdropAction(props: BackdropActionProps) {
|
||||
const handleMouseMove = useCallback(() => {
|
||||
if (!moved) {
|
||||
setTimeout(() => {
|
||||
// If NOT a touch, set moved to true
|
||||
const isTouch = Date.now() - lastTouchEnd.current < 200;
|
||||
if (!isTouch) {
|
||||
setMoved(true);
|
||||
}
|
||||
if (!isTouch) setMoved(true);
|
||||
}, 20);
|
||||
return;
|
||||
}
|
||||
|
||||
// remove after all
|
||||
if (timeout.current) clearTimeout(timeout.current);
|
||||
timeout.current = setTimeout(() => {
|
||||
if (moved) setMoved(false);
|
||||
setMoved(false);
|
||||
timeout.current = null;
|
||||
}, 3000);
|
||||
}, [setMoved, moved]);
|
||||
|
95
src/video/components/actions/CaptionRendererAction.tsx
Normal file
95
src/video/components/actions/CaptionRendererAction.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Transition } from "@/components/Transition";
|
||||
import { useSettings } from "@/state/settings";
|
||||
import { sanitize, parseSubtitles } from "@/backend/helpers/captions";
|
||||
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||
import { useRef } from "react";
|
||||
import { useAsync } from "react-use";
|
||||
import { useVideoPlayerDescriptor } from "../../state/hooks";
|
||||
import { useProgress } from "../../state/logic/progress";
|
||||
import { useSource } from "../../state/logic/source";
|
||||
|
||||
export function CaptionCue({ text, scale }: { text?: string; scale?: number }) {
|
||||
const { captionSettings } = useSettings();
|
||||
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />");
|
||||
|
||||
// https://www.w3.org/TR/webvtt1/#dom-construction-rules
|
||||
// added a <br /> for newlines
|
||||
const html = sanitize(textWithNewlines, {
|
||||
ALLOWED_TAGS: ["c", "b", "i", "u", "span", "ruby", "rt", "br"],
|
||||
ADD_TAGS: ["v", "lang"],
|
||||
ALLOWED_ATTR: ["title", "lang"],
|
||||
});
|
||||
|
||||
return (
|
||||
<p
|
||||
className="pointer-events-none mb-1 select-none rounded px-4 py-1 text-center [text-shadow:0_2px_4px_rgba(0,0,0,0.5)]"
|
||||
style={{
|
||||
...captionSettings.style,
|
||||
fontSize: captionSettings.style.fontSize * (scale ?? 1),
|
||||
}}
|
||||
>
|
||||
<span
|
||||
// its sanitised a few lines up
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: html,
|
||||
}}
|
||||
dir="auto"
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function CaptionRendererAction({
|
||||
isControlsShown,
|
||||
}: {
|
||||
isControlsShown: boolean;
|
||||
}) {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const source = useSource(descriptor).source;
|
||||
const videoTime = useProgress(descriptor).time;
|
||||
const { captionSettings } = useSettings();
|
||||
const captions = useRef<ContentCaption[]>([]);
|
||||
|
||||
useAsync(async () => {
|
||||
const blobUrl = source?.caption?.url;
|
||||
if (blobUrl) {
|
||||
const result = await fetch(blobUrl);
|
||||
const text = await result.text();
|
||||
try {
|
||||
captions.current = parseSubtitles(text);
|
||||
} catch (error) {
|
||||
captions.current = [];
|
||||
}
|
||||
} else {
|
||||
captions.current = [];
|
||||
}
|
||||
}, [source?.caption?.url]);
|
||||
|
||||
if (!captions.current.length) return null;
|
||||
const isVisible = (start: number, end: number): boolean => {
|
||||
const delayedStart = start / 1000 + captionSettings.delay;
|
||||
const delayedEnd = end / 1000 + captionSettings.delay;
|
||||
return (
|
||||
Math.max(0, delayedStart) <= videoTime &&
|
||||
Math.max(0, delayedEnd) >= videoTime
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Transition
|
||||
className={[
|
||||
"pointer-events-none absolute flex w-full flex-col items-center transition-[bottom]",
|
||||
isControlsShown ? "bottom-24" : "bottom-12",
|
||||
].join(" ")}
|
||||
animation="slide-up"
|
||||
show
|
||||
>
|
||||
{captions.current.map(
|
||||
({ start, end, content }) =>
|
||||
isVisible(start, end) && (
|
||||
<CaptionCue key={`${start}-${end}`} text={content} />
|
||||
)
|
||||
)}
|
||||
</Transition>
|
||||
);
|
||||
}
|
@@ -63,6 +63,16 @@ export function KeyboardShortcutsAction() {
|
||||
toggleVolume();
|
||||
break;
|
||||
|
||||
// Decrease volume
|
||||
case "arrowdown":
|
||||
controls.setVolume(Math.max(mediaPlaying.volume - 0.1, 0), true);
|
||||
break;
|
||||
|
||||
// Increase volume
|
||||
case "arrowup":
|
||||
controls.setVolume(Math.min(mediaPlaying.volume + 0.1, 1), true);
|
||||
break;
|
||||
|
||||
// Do a barrel Roll!
|
||||
case "r":
|
||||
if (isRolling || evt.ctrlKey || evt.metaKey) return;
|
||||
|
@@ -1,6 +1,11 @@
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||
import { useProgress } from "@/video/state/logic/progress";
|
||||
import { useInterface } from "@/video/state/logic/interface";
|
||||
import { VideoPlayerTimeFormat } from "@/video/state/types";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
|
||||
function durationExceedsHour(secs: number): boolean {
|
||||
return secs > 60 * 60;
|
||||
@@ -37,19 +42,71 @@ export function TimeAction(props: Props) {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const videoTime = useProgress(descriptor);
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
const { setTimeFormat } = useControls(descriptor);
|
||||
const { timeFormat } = useInterface(descriptor);
|
||||
const { isMobile } = useIsMobile();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasHours = durationExceedsHour(videoTime.duration);
|
||||
const time = formatSeconds(
|
||||
|
||||
const currentTime = formatSeconds(
|
||||
mediaPlaying.isDragSeeking ? videoTime.draggingTime : videoTime.time,
|
||||
hasHours
|
||||
);
|
||||
const duration = formatSeconds(videoTime.duration, hasHours);
|
||||
const timeLeft = formatSeconds(
|
||||
(videoTime.duration - videoTime.time) / mediaPlaying.playbackSpeed,
|
||||
hasHours
|
||||
);
|
||||
const timeFinished = new Date(
|
||||
new Date().getTime() +
|
||||
(videoTime.duration * 1000) / mediaPlaying.playbackSpeed
|
||||
).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
const formattedTimeFinished = ` - ${t("videoPlayer.finishAt", {
|
||||
timeFinished,
|
||||
})}`;
|
||||
|
||||
let formattedTime: string;
|
||||
|
||||
if (timeFormat === VideoPlayerTimeFormat.REGULAR) {
|
||||
formattedTime = `${currentTime} ${props.noDuration ? "" : `/ ${duration}`}`;
|
||||
} else if (timeFormat === VideoPlayerTimeFormat.REMAINING && !isMobile) {
|
||||
formattedTime = `${t("videoPlayer.timeLeft", {
|
||||
timeLeft,
|
||||
})}${videoTime.time === videoTime.duration ? "" : formattedTimeFinished} `;
|
||||
} else if (timeFormat === VideoPlayerTimeFormat.REMAINING && isMobile) {
|
||||
formattedTime = `-${timeLeft}`;
|
||||
} else {
|
||||
formattedTime = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<p className="select-none text-white">
|
||||
{time} {props.noDuration ? "" : `/ ${duration}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
"group pointer-events-auto text-white transition-transform duration-100 active:scale-110",
|
||||
].join(" ")}
|
||||
onClick={() => {
|
||||
setTimeFormat(
|
||||
timeFormat === VideoPlayerTimeFormat.REGULAR
|
||||
? VideoPlayerTimeFormat.REMAINING
|
||||
: VideoPlayerTimeFormat.REGULAR
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
"flex items-center justify-center rounded-full bg-denim-600 bg-opacity-0 p-2 transition-colors duration-100 group-hover:bg-opacity-50 group-active:bg-denim-500 group-active:bg-opacity-100 sm:px-4",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className={props.className}>
|
||||
<p className="select-none text-white">{formattedTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
32
src/video/components/actions/VolumeAdjustedAction.tsx
Normal file
32
src/video/components/actions/VolumeAdjustedAction.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useInterface } from "@/video/state/logic/interface";
|
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||
|
||||
export function VolumeAdjustedAction() {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const videoInterface = useInterface(descriptor);
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
videoInterface.volumeChangedWithKeybind
|
||||
? "mt-10 scale-100 opacity-100"
|
||||
: "mt-5 scale-75 opacity-0",
|
||||
"absolute left-1/2 z-[100] flex -translate-x-1/2 items-center space-x-4 rounded-full bg-bink-300 bg-opacity-50 py-2 px-5 transition-all duration-100",
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon
|
||||
icon={mediaPlaying.volume > 0 ? Icons.VOLUME : Icons.VOLUME_X}
|
||||
className="text-xl text-white"
|
||||
/>
|
||||
<div className="h-2 w-44 overflow-hidden rounded-full bg-denim-100">
|
||||
<div
|
||||
className="h-full rounded-r-full bg-bink-500 transition-[width] duration-100"
|
||||
style={{ width: `${mediaPlaying.volume * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -0,0 +1,17 @@
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
||||
|
||||
interface Props {
|
||||
onClick: () => any;
|
||||
}
|
||||
|
||||
export function PlaybackSpeedSelectionAction(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PopoutListAction icon={Icons.TACHOMETER} onClick={props.onClick}>
|
||||
{t("videoPlayer.buttons.playbackSpeed")}
|
||||
</PopoutListAction>
|
||||
);
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
||||
import { QualityDisplayAction } from "./QualityDisplayAction";
|
||||
|
@@ -1,4 +1,11 @@
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||
import { getCaptionUrl, makeCaptionId } from "@/backend/helpers/captions";
|
||||
import {
|
||||
MWCaption,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "@/backend/helpers/streams";
|
||||
import { captionLanguages } from "@/setup/iso6391";
|
||||
import { useSettings } from "@/state/settings";
|
||||
import { useInitialized } from "@/video/components/hooks/useInitialized";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
@@ -10,6 +17,19 @@ interface SourceControllerProps {
|
||||
quality: MWStreamQuality;
|
||||
providerId?: string;
|
||||
embedId?: string;
|
||||
captions: MWCaption[];
|
||||
}
|
||||
async function tryFetch(captions: MWCaption[]) {
|
||||
for (let i = 0; i < captions.length; i += 1) {
|
||||
const caption = captions[i];
|
||||
try {
|
||||
const blobUrl = await getCaptionUrl(caption);
|
||||
return { caption, blobUrl };
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SourceController(props: SourceControllerProps) {
|
||||
@@ -17,13 +37,35 @@ export function SourceController(props: SourceControllerProps) {
|
||||
const controls = useControls(descriptor);
|
||||
const { initialized } = useInitialized(descriptor);
|
||||
const didInitialize = useRef<boolean>(false);
|
||||
|
||||
const { captionSettings } = useSettings();
|
||||
useEffect(() => {
|
||||
if (didInitialize.current) return;
|
||||
if (!initialized) return;
|
||||
controls.setSource(props);
|
||||
// get preferred language
|
||||
const preferredLanguage = captionLanguages.find(
|
||||
(v) => v.id === captionSettings.language
|
||||
);
|
||||
if (!preferredLanguage) return;
|
||||
const captions = props.captions.filter(
|
||||
(v) =>
|
||||
// langIso may contain the English name or the native name of the language
|
||||
v.langIso.indexOf(preferredLanguage.englishName) !== -1 ||
|
||||
v.langIso.indexOf(preferredLanguage.nativeName) !== -1
|
||||
);
|
||||
if (!captions) return;
|
||||
// caption url can return a response other than 200
|
||||
// that's why we fetch until we get a 200 response
|
||||
tryFetch(captions).then((response) => {
|
||||
// none of them were successful
|
||||
if (!response) return;
|
||||
// set the preferred language
|
||||
const id = makeCaptionId(response.caption, true);
|
||||
controls.setCaption(id, response.blobUrl);
|
||||
});
|
||||
|
||||
didInitialize.current = true;
|
||||
}, [props, controls, initialized]);
|
||||
}, [props, controls, initialized, captionSettings.language]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||
import { useMisc } from "@/video/state/logic/misc";
|
||||
import { useSource } from "@/video/state/logic/source";
|
||||
import { setProvider, unsetStateProvider } from "@/video/state/providers/utils";
|
||||
import { createVideoStateProvider } from "@/video/state/providers/videoStateProvider";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
@@ -13,7 +12,6 @@ interface Props {
|
||||
function VideoElement(props: Props) {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
const source = useSource(descriptor);
|
||||
const misc = useMisc(descriptor);
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
|
||||
@@ -43,12 +41,8 @@ function VideoElement(props: Props) {
|
||||
autoPlay={props.autoPlay}
|
||||
muted={mediaPlaying.volume === 0}
|
||||
playsInline
|
||||
className="h-full w-full"
|
||||
>
|
||||
{source.source?.caption ? (
|
||||
<track default kind="captions" src={source.source.caption.url} />
|
||||
) : null}
|
||||
</video>
|
||||
className="z-0 h-full w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -2,8 +2,9 @@ import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
||||
import { Link } from "@/components/text/Link";
|
||||
import { conf } from "@/setup/config";
|
||||
import { Component, ReactNode } from "react";
|
||||
import { Component } from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
|
||||
import { VideoPlayerHeader } from "./VideoPlayerHeader";
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
|
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
customCaption,
|
||||
getCaptionUrl,
|
||||
convertCustomCaptionFileToWebVTT,
|
||||
CUSTOM_CAPTION_ID,
|
||||
makeCaptionId,
|
||||
parseSubtitles,
|
||||
subtitleTypeList,
|
||||
} from "@/backend/helpers/captions";
|
||||
import { MWCaption } from "@/backend/helpers/streams";
|
||||
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { FloatingCardView } from "@/components/popout/FloatingCard";
|
||||
import { FloatingView } from "@/components/popout/FloatingView";
|
||||
@@ -13,14 +15,10 @@ 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 { ChangeEvent, useMemo, useRef } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
|
||||
function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
|
||||
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
|
||||
}
|
||||
|
||||
export function CaptionSelectionPopout(props: {
|
||||
router: ReturnType<typeof useFloatingRouter>;
|
||||
prefix: string;
|
||||
@@ -41,36 +39,20 @@ export function CaptionSelectionPopout(props: {
|
||||
async (caption: MWCaption, isLinked: boolean) => {
|
||||
const id = makeCaptionId(caption, isLinked);
|
||||
loadingId.current = id;
|
||||
controls.setCaption(id, await getCaptionUrl(caption));
|
||||
controls.closePopout();
|
||||
const blobUrl = await getCaptionUrl(caption);
|
||||
const result = await fetch(blobUrl);
|
||||
const text = await result.text();
|
||||
parseSubtitles(text); // This will throw if the file is invalid
|
||||
controls.setCaption(id, blobUrl);
|
||||
// sometimes this doesn't work, so we add a small delay
|
||||
setTimeout(() => {
|
||||
controls.closePopout();
|
||||
}, 100);
|
||||
}
|
||||
);
|
||||
|
||||
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 (
|
||||
<FloatingView
|
||||
{...props.router.pageProps(props.prefix)}
|
||||
@@ -81,6 +63,18 @@ export function CaptionSelectionPopout(props: {
|
||||
title={t("videoPlayer.popouts.captions")}
|
||||
description={t("videoPlayer.popouts.descriptions.captions")}
|
||||
goBack={() => props.router.navigate("/")}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
props.router.navigate(`${props.prefix}/caption-settings`)
|
||||
}
|
||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
||||
>
|
||||
<span>{t("videoPlayer.popouts.captionPreferences.title")}</span>
|
||||
<Icon icon={Icons.GEAR} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<FloatingCardView.Content noSection>
|
||||
<PopoutSection>
|
||||
@@ -94,23 +88,29 @@ export function CaptionSelectionPopout(props: {
|
||||
{t("videoPlayer.popouts.noCaptions")}
|
||||
</PopoutListEntry>
|
||||
<PopoutListEntry
|
||||
key={CUSTOM_CAPTION_ID}
|
||||
active={currentCaption === CUSTOM_CAPTION_ID}
|
||||
loading={loadingCustomCaption}
|
||||
errored={!!errorCustomCaption}
|
||||
onClick={() => {
|
||||
customCaptionUploadElement.current?.click();
|
||||
}}
|
||||
key={customCaption}
|
||||
active={currentCaption === customCaption}
|
||||
loading={loading && loadingId.current === customCaption}
|
||||
errored={error && loadingId.current === customCaption}
|
||||
onClick={() => customCaptionUploadElement.current?.click()}
|
||||
>
|
||||
{currentCaption === CUSTOM_CAPTION_ID
|
||||
{currentCaption === customCaption
|
||||
? t("videoPlayer.popouts.customCaption")
|
||||
: t("videoPlayer.popouts.uploadCustomCaption")}
|
||||
<input
|
||||
ref={customCaptionUploadElement}
|
||||
type="file"
|
||||
onChange={handleUploadCaption}
|
||||
className="hidden"
|
||||
accept=".vtt, .srt"
|
||||
ref={customCaptionUploadElement}
|
||||
accept={subtitleTypeList.join(",")}
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
if (!e.target.files) return;
|
||||
const customSubtitle = {
|
||||
langIso: "custom",
|
||||
url: URL.createObjectURL(e.target.files[0]),
|
||||
type: MWCaptionType.UNKNOWN,
|
||||
};
|
||||
setCaption(customSubtitle, false);
|
||||
}}
|
||||
/>
|
||||
</PopoutListEntry>
|
||||
</PopoutSection>
|
||||
|
81
src/video/components/popouts/CaptionSettingsPopout.tsx
Normal file
81
src/video/components/popouts/CaptionSettingsPopout.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { FloatingCardView } from "@/components/popout/FloatingCard";
|
||||
import { FloatingView } from "@/components/popout/FloatingView";
|
||||
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
|
||||
import { useSettings } from "@/state/settings";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Slider } from "@/components/Slider";
|
||||
import CaptionColorSelector, {
|
||||
colors,
|
||||
} from "@/components/CaptionColorSelector";
|
||||
|
||||
export function CaptionSettingsPopout(props: {
|
||||
router: ReturnType<typeof useFloatingRouter>;
|
||||
prefix: string;
|
||||
}) {
|
||||
// For now, won't add label texts to language files since options are prone to change
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
captionSettings,
|
||||
setCaptionBackgroundColor,
|
||||
setCaptionDelay,
|
||||
setCaptionFontSize,
|
||||
} = useSettings();
|
||||
return (
|
||||
<FloatingView {...props.router.pageProps(props.prefix)} width={375}>
|
||||
<FloatingCardView.Header
|
||||
title={t("videoPlayer.popouts.captionPreferences.title")}
|
||||
description={t("videoPlayer.popouts.descriptions.captionPreferences")}
|
||||
goBack={() => props.router.navigate("/captions")}
|
||||
/>
|
||||
<FloatingCardView.Content>
|
||||
<Slider
|
||||
label={t("videoPlayer.popouts.captionPreferences.delay") as string}
|
||||
max={10}
|
||||
min={-10}
|
||||
step={0.1}
|
||||
valueDisplay={`${captionSettings.delay.toFixed(1)}s`}
|
||||
value={captionSettings.delay}
|
||||
onChange={(e) => setCaptionDelay(e.target.valueAsNumber)}
|
||||
/>
|
||||
<Slider
|
||||
label={t("videoPlayer.popouts.captionPreferences.fontSize") as string}
|
||||
min={14}
|
||||
step={1}
|
||||
max={60}
|
||||
value={captionSettings.style.fontSize}
|
||||
onChange={(e) => setCaptionFontSize(e.target.valueAsNumber)}
|
||||
/>
|
||||
<Slider
|
||||
label={t("videoPlayer.popouts.captionPreferences.opacity") as string}
|
||||
step={1}
|
||||
min={0}
|
||||
max={255}
|
||||
valueDisplay={`${(
|
||||
(parseInt(
|
||||
captionSettings.style.backgroundColor.substring(7, 9),
|
||||
16
|
||||
) /
|
||||
255) *
|
||||
100
|
||||
).toFixed(0)}%`}
|
||||
value={parseInt(
|
||||
captionSettings.style.backgroundColor.substring(7, 9),
|
||||
16
|
||||
)}
|
||||
onChange={(e) => setCaptionBackgroundColor(e.target.valueAsNumber)}
|
||||
/>
|
||||
<div className="flex flex-row justify-between">
|
||||
<label className="font-bold" htmlFor="color">
|
||||
{t("videoPlayer.popouts.captionPreferences.color")}
|
||||
</label>
|
||||
<div className="flex flex-row gap-2">
|
||||
{colors.map((color) => (
|
||||
<CaptionColorSelector color={color} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FloatingCardView.Content>
|
||||
</FloatingView>
|
||||
);
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
|
73
src/video/components/popouts/PlaybackSpeedPopout.tsx
Normal file
73
src/video/components/popouts/PlaybackSpeedPopout.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { FloatingCardView } from "@/components/popout/FloatingCard";
|
||||
import { FloatingView } from "@/components/popout/FloatingView";
|
||||
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||
import { Slider } from "@/components/Slider";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
|
||||
const speedSelectionOptions = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
export function PlaybackSpeedPopout(props: {
|
||||
router: ReturnType<typeof useFloatingRouter>;
|
||||
prefix: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const controls = useControls(descriptor);
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
|
||||
return (
|
||||
<FloatingView
|
||||
{...props.router.pageProps(props.prefix)}
|
||||
width={320}
|
||||
height={500}
|
||||
>
|
||||
<FloatingCardView.Header
|
||||
title={t("videoPlayer.popouts.playbackSpeed")}
|
||||
description={t("videoPlayer.popouts.descriptions.playbackSpeed")}
|
||||
goBack={() => props.router.navigate("/")}
|
||||
/>
|
||||
<FloatingCardView.Content noSection>
|
||||
<PopoutSection>
|
||||
{speedSelectionOptions.map((speed) => (
|
||||
<PopoutListEntry
|
||||
key={speed}
|
||||
active={mediaPlaying.playbackSpeed === speed}
|
||||
onClick={() => {
|
||||
controls.setPlaybackSpeed(speed);
|
||||
controls.closePopout();
|
||||
}}
|
||||
>
|
||||
{speed}x
|
||||
</PopoutListEntry>
|
||||
))}
|
||||
</PopoutSection>
|
||||
|
||||
<p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-300 px-5 py-3 text-xs font-bold uppercase">
|
||||
<Icon className="text-base" icon={Icons.TACHOMETER} />
|
||||
<span>{t("videoPlayer.popouts.customPlaybackSpeed")}</span>
|
||||
</p>
|
||||
|
||||
<PopoutSection className="pt-0">
|
||||
<div>
|
||||
<Slider
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={mediaPlaying.playbackSpeed}
|
||||
valueDisplay={`${mediaPlaying.playbackSpeed}x`}
|
||||
onChange={(e: { target: { valueAsNumber: number } }) =>
|
||||
controls.setPlaybackSpeed(e.target.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PopoutSection>
|
||||
</FloatingCardView.Content>
|
||||
</FloatingView>
|
||||
);
|
||||
}
|
@@ -43,6 +43,8 @@ export function ScrollToActive(props: ScrollToActiveProps) {
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
const inited = useRef<boolean>(false);
|
||||
|
||||
const SAFE_OFFSET = 30;
|
||||
|
||||
// Scroll to "active" child on first load (AKA mount except React dumb)
|
||||
useEffect(() => {
|
||||
if (inited.current) return;
|
||||
@@ -61,27 +63,31 @@ export function ScrollToActive(props: ScrollToActiveProps) {
|
||||
wrapper?.querySelector(".active");
|
||||
|
||||
if (wrapper && active) {
|
||||
let activeYPositionCentered = 0;
|
||||
const setActiveYPositionCentered = () => {
|
||||
activeYPositionCentered =
|
||||
active.getBoundingClientRect().top -
|
||||
wrapper.getBoundingClientRect().top +
|
||||
active.offsetHeight / 2;
|
||||
let wrapperHeight = 0;
|
||||
let activePos = 0;
|
||||
let activeHeight = 0;
|
||||
let wrapperScroll = 0;
|
||||
|
||||
const getCoords = () => {
|
||||
const activeRect = active.getBoundingClientRect();
|
||||
const wrapperRect = wrapper.getBoundingClientRect();
|
||||
wrapperHeight = wrapperRect.height;
|
||||
activeHeight = activeRect.height;
|
||||
activePos = activeRect.top - wrapperRect.top + wrapper.scrollTop;
|
||||
wrapperScroll = wrapper.scrollTop;
|
||||
};
|
||||
setActiveYPositionCentered();
|
||||
getCoords();
|
||||
|
||||
if (activeYPositionCentered >= wrapper.offsetHeight / 2) {
|
||||
// Check if the active element is below the vertical center line, then scroll it into center
|
||||
const isVisible =
|
||||
activePos + activeHeight <
|
||||
wrapperScroll + wrapperHeight - SAFE_OFFSET ||
|
||||
activePos > wrapperScroll + SAFE_OFFSET;
|
||||
if (isVisible) {
|
||||
const activeMiddlePos = activePos + activeHeight / 2; // pos of middle of active element
|
||||
const viewMiddle = wrapperHeight / 2; // half of the available height
|
||||
const pos = activeMiddlePos - viewMiddle;
|
||||
wrapper.scrollTo({
|
||||
top: activeYPositionCentered - wrapper.offsetHeight / 2,
|
||||
});
|
||||
}
|
||||
|
||||
setActiveYPositionCentered();
|
||||
if (activeYPositionCentered > wrapper.offsetHeight / 2) {
|
||||
// If the element is over the vertical center line, scroll to the end
|
||||
wrapper.scrollTo({
|
||||
top: wrapper.scrollHeight,
|
||||
top: pos,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -5,8 +5,11 @@ import { useFloatingRouter } from "@/hooks/useFloatingRouter";
|
||||
import { DownloadAction } from "@/video/components/actions/list-entries/DownloadAction";
|
||||
import { CaptionsSelectionAction } from "@/video/components/actions/list-entries/CaptionsSelectionAction";
|
||||
import { SourceSelectionAction } from "@/video/components/actions/list-entries/SourceSelectionAction";
|
||||
import { PlaybackSpeedSelectionAction } from "@/video/components/actions/list-entries/PlaybackSpeedSelectionAction";
|
||||
import { CaptionSelectionPopout } from "./CaptionSelectionPopout";
|
||||
import { SourceSelectionPopout } from "./SourceSelectionPopout";
|
||||
import { CaptionSettingsPopout } from "./CaptionSettingsPopout";
|
||||
import { PlaybackSpeedPopout } from "./PlaybackSpeedPopout";
|
||||
|
||||
export function SettingsPopout() {
|
||||
const floatingRouter = useFloatingRouter();
|
||||
@@ -20,10 +23,18 @@ export function SettingsPopout() {
|
||||
<DownloadAction />
|
||||
<SourceSelectionAction onClick={() => navigate("/source")} />
|
||||
<CaptionsSelectionAction onClick={() => navigate("/captions")} />
|
||||
<PlaybackSpeedSelectionAction
|
||||
onClick={() => navigate("/playback-speed")}
|
||||
/>
|
||||
</FloatingCardView.Content>
|
||||
</FloatingView>
|
||||
<SourceSelectionPopout router={floatingRouter} prefix="source" />
|
||||
<CaptionSelectionPopout router={floatingRouter} prefix="captions" />
|
||||
<CaptionSettingsPopout
|
||||
router={floatingRouter}
|
||||
prefix="caption-settings"
|
||||
/>
|
||||
<PlaybackSpeedPopout router={floatingRouter} prefix="playback-speed" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -13,6 +13,7 @@ export function resetForSource(s: VideoPlayerState) {
|
||||
isFirstLoading: true,
|
||||
hasPlayedOnce: false,
|
||||
volume: state.mediaPlaying.volume, // volume settings needs to persist through resets
|
||||
playbackSpeed: 1,
|
||||
};
|
||||
state.progress = {
|
||||
time: 0,
|
||||
@@ -31,6 +32,9 @@ function initPlayer(): VideoPlayerState {
|
||||
isFocused: false,
|
||||
leftControlHovering: false,
|
||||
popoutBounds: null,
|
||||
volumeChangedWithKeybind: false,
|
||||
volumeChangedWithKeybindDebounce: null,
|
||||
timeFormat: 0,
|
||||
},
|
||||
|
||||
mediaPlaying: {
|
||||
@@ -42,6 +46,7 @@ function initPlayer(): VideoPlayerState {
|
||||
isFirstLoading: true,
|
||||
hasPlayedOnce: false,
|
||||
volume: 0,
|
||||
playbackSpeed: 1,
|
||||
},
|
||||
|
||||
progress: {
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { updateInterface } from "@/video/state/logic/interface";
|
||||
import { updateMeta } from "@/video/state/logic/meta";
|
||||
import { updateProgress } from "@/video/state/logic/progress";
|
||||
import { VideoPlayerMeta } from "@/video/state/types";
|
||||
import { VideoPlayerMeta, VideoPlayerTimeFormat } from "@/video/state/types";
|
||||
import { getPlayerState } from "../cache";
|
||||
import { VideoPlayerStateController } from "../providers/providerTypes";
|
||||
|
||||
@@ -14,6 +14,8 @@ export type ControlMethods = {
|
||||
setCurrentEpisode(sId: string, eId: string): void;
|
||||
setDraggingTime(num: number): void;
|
||||
togglePictureInPicture(): void;
|
||||
setPlaybackSpeed(num: number): void;
|
||||
setTimeFormat(num: VideoPlayerTimeFormat): void;
|
||||
};
|
||||
|
||||
export function useControls(
|
||||
@@ -47,8 +49,20 @@ export function useControls(
|
||||
enterFullscreen() {
|
||||
state.stateProvider?.enterFullscreen();
|
||||
},
|
||||
setVolume(volume) {
|
||||
state.stateProvider?.setVolume(volume);
|
||||
setVolume(volume, isKeyboardEvent = false) {
|
||||
if (isKeyboardEvent) {
|
||||
if (state.interface.volumeChangedWithKeybindDebounce)
|
||||
clearTimeout(state.interface.volumeChangedWithKeybindDebounce);
|
||||
|
||||
state.interface.volumeChangedWithKeybind = true;
|
||||
updateInterface(descriptor, state);
|
||||
|
||||
state.interface.volumeChangedWithKeybindDebounce = setTimeout(() => {
|
||||
state.interface.volumeChangedWithKeybind = false;
|
||||
updateInterface(descriptor, state);
|
||||
}, 3e3);
|
||||
}
|
||||
state.stateProvider?.setVolume(volume, isKeyboardEvent);
|
||||
},
|
||||
startAirplay() {
|
||||
state.stateProvider?.startAirplay();
|
||||
@@ -105,5 +119,13 @@ export function useControls(
|
||||
state.stateProvider?.togglePictureInPicture();
|
||||
updateInterface(descriptor, state);
|
||||
},
|
||||
setPlaybackSpeed(num) {
|
||||
state.stateProvider?.setPlaybackSpeed(num);
|
||||
updateInterface(descriptor, state);
|
||||
},
|
||||
setTimeFormat(format) {
|
||||
state.interface.timeFormat = format;
|
||||
updateInterface(descriptor, state);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getPlayerState } from "../cache";
|
||||
import { listenEvent, sendEvent, unlistenEvent } from "../events";
|
||||
import { VideoPlayerState } from "../types";
|
||||
import { VideoPlayerState, VideoPlayerTimeFormat } from "../types";
|
||||
|
||||
export type VideoInterfaceEvent = {
|
||||
popout: string | null;
|
||||
@@ -9,6 +9,8 @@ export type VideoInterfaceEvent = {
|
||||
isFocused: boolean;
|
||||
isFullscreen: boolean;
|
||||
popoutBounds: null | DOMRect;
|
||||
volumeChangedWithKeybind: boolean;
|
||||
timeFormat: VideoPlayerTimeFormat;
|
||||
};
|
||||
|
||||
function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent {
|
||||
@@ -18,6 +20,8 @@ function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent {
|
||||
isFocused: state.interface.isFocused,
|
||||
isFullscreen: state.interface.isFullscreen,
|
||||
popoutBounds: state.interface.popoutBounds,
|
||||
volumeChangedWithKeybind: state.interface.volumeChangedWithKeybind,
|
||||
timeFormat: state.interface.timeFormat,
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -12,6 +12,7 @@ export type VideoMediaPlayingEvent = {
|
||||
hasPlayedOnce: boolean;
|
||||
isFirstLoading: boolean;
|
||||
volume: number;
|
||||
playbackSpeed: number;
|
||||
};
|
||||
|
||||
function getMediaPlayingFromState(
|
||||
@@ -26,6 +27,7 @@ function getMediaPlayingFromState(
|
||||
isDragSeeking: state.mediaPlaying.isDragSeeking,
|
||||
isFirstLoading: state.mediaPlaying.isFirstLoading,
|
||||
volume: state.mediaPlaying.volume,
|
||||
playbackSpeed: state.mediaPlaying.playbackSpeed,
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -87,6 +87,23 @@ export function createCastingStateProvider(
|
||||
togglePictureInPicture() {
|
||||
// no picture in picture while casting
|
||||
},
|
||||
setPlaybackSpeed(num) {
|
||||
const mediaInfo = new chrome.cast.media.MediaInfo(
|
||||
state.meta?.meta.meta.id ?? "video",
|
||||
"video/mp4"
|
||||
);
|
||||
(mediaInfo as any).contentUrl = state.source?.url;
|
||||
mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED;
|
||||
mediaInfo.metadata = new chrome.cast.media.MovieMediaMetadata();
|
||||
mediaInfo.metadata.title = state.meta?.meta.meta.title ?? "";
|
||||
mediaInfo.customData = {
|
||||
playbackRate: num,
|
||||
};
|
||||
const request = new chrome.cast.media.LoadRequest(mediaInfo);
|
||||
request.autoplay = true;
|
||||
const session = ins?.getCurrentSession();
|
||||
session?.loadMedia(request);
|
||||
},
|
||||
async setVolume(v) {
|
||||
// clamp time between 0 and 1
|
||||
let volume = Math.min(v, 1);
|
||||
@@ -114,7 +131,7 @@ export function createCastingStateProvider(
|
||||
movieMeta.title = state.meta?.meta.meta.title ?? "";
|
||||
|
||||
const mediaInfo = new chrome.cast.media.MediaInfo(
|
||||
state.meta?.meta.meta.id ?? "hello",
|
||||
state.meta?.meta.meta.id ?? "video",
|
||||
"video/mp4"
|
||||
);
|
||||
(mediaInfo as any).contentUrl = source?.source;
|
||||
|
@@ -16,12 +16,13 @@ export type VideoPlayerStateController = {
|
||||
setSeeking(active: boolean): void;
|
||||
exitFullscreen(): void;
|
||||
enterFullscreen(): void;
|
||||
setVolume(volume: number): void;
|
||||
setVolume(volume: number, isKeyboardEvent?: boolean): void;
|
||||
startAirplay(): void;
|
||||
setCaption(id: string, url: string): void;
|
||||
clearCaption(): void;
|
||||
getId(): string;
|
||||
togglePictureInPicture(): void;
|
||||
setPlaybackSpeed(num: number): void;
|
||||
};
|
||||
|
||||
export type VideoPlayerStateProvider = VideoPlayerStateController & {
|
||||
|
@@ -228,6 +228,11 @@ export function createVideoStateProvider(
|
||||
}
|
||||
}
|
||||
},
|
||||
setPlaybackSpeed(num) {
|
||||
player.playbackRate = num;
|
||||
state.mediaPlaying.playbackSpeed = num;
|
||||
updateMediaPlaying(descriptor, state);
|
||||
},
|
||||
providerStart() {
|
||||
this.setVolume(getStoredVolume());
|
||||
|
||||
@@ -276,8 +281,14 @@ export function createVideoStateProvider(
|
||||
state.mediaPlaying.isLoading = false;
|
||||
updateMediaPlaying(descriptor, state);
|
||||
};
|
||||
const ratechange = () => {
|
||||
state.mediaPlaying.playbackSpeed = player.playbackRate;
|
||||
updateMediaPlaying(descriptor, state);
|
||||
};
|
||||
const fullscreenchange = () => {
|
||||
state.interface.isFullscreen = !!document.fullscreenElement;
|
||||
state.interface.isFullscreen =
|
||||
!!document.fullscreenElement || // other browsers
|
||||
!!(document as any).webkitFullscreenElement; // safari
|
||||
updateInterface(descriptor, state);
|
||||
};
|
||||
const volumechange = async () => {
|
||||
@@ -324,6 +335,7 @@ export function createVideoStateProvider(
|
||||
player.addEventListener("timeupdate", timeupdate);
|
||||
player.addEventListener("loadedmetadata", loadedmetadata);
|
||||
player.addEventListener("canplay", canplay);
|
||||
player.addEventListener("ratechange", ratechange);
|
||||
fscreen.addEventListener("fullscreenchange", fullscreenchange);
|
||||
player.addEventListener("error", error);
|
||||
player.addEventListener(
|
||||
|
@@ -22,14 +22,22 @@ export type VideoPlayerMeta = {
|
||||
}[];
|
||||
};
|
||||
|
||||
export enum VideoPlayerTimeFormat {
|
||||
REGULAR = 0,
|
||||
REMAINING = 1,
|
||||
}
|
||||
|
||||
export type VideoPlayerState = {
|
||||
// state related to the user interface
|
||||
interface: {
|
||||
isFullscreen: boolean;
|
||||
popout: string | null; // id of current popout (eg source select, episode select)
|
||||
isFocused: boolean; // is the video player the users focus? (shortcuts only works when its focused)
|
||||
volumeChangedWithKeybind: boolean; // has the volume recently been adjusted with the up/down arrows recently?
|
||||
volumeChangedWithKeybindDebounce: NodeJS.Timeout | null; // debounce for the duration of the "volume changed thingamajig"
|
||||
leftControlHovering: boolean; // is the cursor hovered over the left side of player controls
|
||||
popoutBounds: null | DOMRect; // bounding box of current popout
|
||||
timeFormat: VideoPlayerTimeFormat; // Time format of the video player
|
||||
};
|
||||
|
||||
// state related to the playing state of the media
|
||||
@@ -42,6 +50,7 @@ export type VideoPlayerState = {
|
||||
isFirstLoading: boolean; // first buffering of the video, when set to false the video can start playing
|
||||
hasPlayedOnce: boolean; // has the video played at all?
|
||||
volume: number;
|
||||
playbackSpeed: number;
|
||||
};
|
||||
|
||||
// state related to video progress
|
||||
|
147
src/views/SettingsModal.tsx
Normal file
147
src/views/SettingsModal.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Dropdown } from "@/components/Dropdown";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Modal, ModalCard } from "@/components/layout/Modal";
|
||||
import { useSettings } from "@/state/settings";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CaptionCue } from "@/video/components/actions/CaptionRendererAction";
|
||||
import {
|
||||
CaptionLanguageOption,
|
||||
LangCode,
|
||||
captionLanguages,
|
||||
} from "@/setup/iso6391";
|
||||
import { useMemo } from "react";
|
||||
import { appLanguageOptions } from "@/setup/i18n";
|
||||
import CaptionColorSelector, {
|
||||
colors,
|
||||
} from "@/components/CaptionColorSelector";
|
||||
import { Slider } from "@/components/Slider";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
export default function SettingsModal(props: {
|
||||
onClose: () => void;
|
||||
show: boolean;
|
||||
}) {
|
||||
const {
|
||||
captionSettings,
|
||||
language,
|
||||
setLanguage,
|
||||
setCaptionLanguage,
|
||||
setCaptionBackgroundColor,
|
||||
setCaptionFontSize,
|
||||
} = useSettings();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const selectedCaptionLanguage = useMemo(
|
||||
() => captionLanguages.find((l) => l.id === captionSettings.language),
|
||||
[captionSettings.language]
|
||||
) as CaptionLanguageOption;
|
||||
const appLanguage = useMemo(
|
||||
() => appLanguageOptions.find((l) => l.id === language),
|
||||
[language]
|
||||
) as CaptionLanguageOption;
|
||||
const captionBackgroundOpacity = (
|
||||
(parseInt(captionSettings.style.backgroundColor.substring(7, 9), 16) /
|
||||
255) *
|
||||
100
|
||||
).toFixed(0);
|
||||
return (
|
||||
<Modal show={props.show}>
|
||||
<ModalCard className="text-white">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row justify-between">
|
||||
<span className="text-xl font-bold">{t("settings.title")}</span>
|
||||
<div
|
||||
onClick={() => props.onClose()}
|
||||
className="hover:cursor-pointer"
|
||||
>
|
||||
<Icon icon={Icons.X} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-10 lg:flex-row">
|
||||
<div className="lg:w-1/2">
|
||||
<div className="flex flex-col justify-between">
|
||||
<label className="text-md font-semibold">
|
||||
{t("settings.language")}
|
||||
</label>
|
||||
<Dropdown
|
||||
selectedItem={appLanguage}
|
||||
setSelectedItem={(val) => {
|
||||
i18n.changeLanguage(val.id);
|
||||
setLanguage(val.id as LangCode);
|
||||
}}
|
||||
options={appLanguageOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between">
|
||||
<label className="text-md font-semibold">
|
||||
{t("settings.captionLanguage")}
|
||||
</label>
|
||||
<Dropdown
|
||||
selectedItem={selectedCaptionLanguage}
|
||||
setSelectedItem={(val) => {
|
||||
setCaptionLanguage(val.id as LangCode);
|
||||
}}
|
||||
options={captionLanguages}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between">
|
||||
<Slider
|
||||
label={
|
||||
t(
|
||||
"videoPlayer.popouts.captionPreferences.fontSize"
|
||||
) as string
|
||||
}
|
||||
min={14}
|
||||
step={1}
|
||||
max={60}
|
||||
value={captionSettings.style.fontSize}
|
||||
onChange={(e) => setCaptionFontSize(e.target.valueAsNumber)}
|
||||
/>
|
||||
<Slider
|
||||
label={
|
||||
t(
|
||||
"videoPlayer.popouts.captionPreferences.opacity"
|
||||
) as string
|
||||
}
|
||||
step={1}
|
||||
min={0}
|
||||
max={255}
|
||||
valueDisplay={`${captionBackgroundOpacity}%`}
|
||||
value={parseInt(
|
||||
captionSettings.style.backgroundColor.substring(7, 9),
|
||||
16
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setCaptionBackgroundColor(e.target.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-row justify-between">
|
||||
<label className="font-bold" htmlFor="color">
|
||||
{t("videoPlayer.popouts.captionPreferences.color")}
|
||||
</label>
|
||||
<div className="flex flex-row gap-2">
|
||||
{colors.map((color) => (
|
||||
<CaptionColorSelector color={color} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<div className="flex w-full flex-col justify-center">
|
||||
<div className="flex aspect-video flex-col justify-end rounded bg-zinc-800">
|
||||
<div className="pointer-events-none flex w-full flex-col items-center transition-[bottom]">
|
||||
<CaptionCue
|
||||
scale={0.5}
|
||||
text={selectedCaptionLanguage.nativeName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="float-right mt-1 text-sm">v{conf().APP_VERSION}</div>
|
||||
</ModalCard>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@@ -3,7 +3,7 @@ import { ThinContainer } from "@/components/layout/ThinContainer";
|
||||
import { ArrowLink } from "@/components/text/ArrowLink";
|
||||
import { Title } from "@/components/text/Title";
|
||||
|
||||
export function DeveloperView() {
|
||||
export default function DeveloperView() {
|
||||
return (
|
||||
<div className="py-48">
|
||||
<Navigation />
|
||||
|
@@ -105,7 +105,7 @@ function EmbedScraperSelector(props: EmbedScraperSelectorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function EmbedTesterView() {
|
||||
export default function EmbedTesterView() {
|
||||
const [embed, setEmbed] = useState<MWEmbed | null>(null);
|
||||
const [embedScraperId, setEmbedScraperId] = useState<string | null>(null);
|
||||
const embedScraper = useMemo(
|
||||
|
@@ -96,7 +96,7 @@ function ProviderSelector(props: ProviderSelectorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderTesterView() {
|
||||
export default function ProviderTesterView() {
|
||||
const [media, setMedia] = useState<DetailedMeta | null>(null);
|
||||
const [providerId, setProviderId] = useState<string | null>(null);
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
// simple empty view, perfect for putting in tests
|
||||
export function TestView() {
|
||||
export default function TestView() {
|
||||
return <div />;
|
||||
}
|
||||
|
@@ -33,7 +33,7 @@ const testMeta: DetailedMeta = {
|
||||
},
|
||||
};
|
||||
|
||||
export function VideoTesterView() {
|
||||
export default function VideoTesterView() {
|
||||
const [video, setVideo] = useState<VideoData | null>(null);
|
||||
const [videoType, setVideoType] = useState<MWStreamType>(MWStreamType.MP4);
|
||||
const [url, setUrl] = useState("");
|
||||
@@ -64,8 +64,9 @@ export function VideoTesterView() {
|
||||
/>
|
||||
<SourceController
|
||||
source={video.streamUrl}
|
||||
type={MWStreamType.MP4}
|
||||
quality={MWStreamQuality.Q720P}
|
||||
type={videoType}
|
||||
quality={MWStreamQuality.QUNKNOWN}
|
||||
captions={[]}
|
||||
/>
|
||||
</VideoPlayer>
|
||||
</div>
|
||||
|
@@ -148,6 +148,7 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
|
||||
quality={props.stream.quality}
|
||||
embedId={props.stream.embedId}
|
||||
providerId={props.stream.providerId}
|
||||
captions={props.stream.captions}
|
||||
/>
|
||||
<ProgressListenerController
|
||||
startAt={firstStartTime.current}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import loadVersion from "vite-plugin-package-version";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import checker from "vite-plugin-checker";
|
||||
@@ -7,10 +7,25 @@ import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
react({
|
||||
babel: {
|
||||
presets: [
|
||||
"@babel/preset-typescript",
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
modules: false,
|
||||
useBuiltIns: "entry",
|
||||
corejs: {
|
||||
version: "3.29",
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
injectRegister: "inline",
|
||||
workbox: {
|
||||
globIgnores: ["**ping.txt**"],
|
||||
},
|
||||
|
Reference in New Issue
Block a user