mirror of
https://github.com/movie-web/movie-web.git
synced 2025-09-13 18:13:24 +00:00
Compare commits
121 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
5468a4677b | ||
|
85cfba1a7a | ||
|
fd6895c326 | ||
|
dfc3d9e50f | ||
|
fcdf45d3f5 | ||
|
592837e2a6 | ||
|
9b3c1ffa28 | ||
|
7cb9ccaf14 | ||
|
4c0c61b0b9 | ||
|
4880d46dc4 | ||
|
8200079af7 | ||
|
dcb5d2f068 | ||
|
99e47f16ea | ||
|
6fb76908ae | ||
|
a718abdcdd | ||
|
106290070a | ||
|
433d618096 | ||
|
af954af36c | ||
|
41979712c3 | ||
|
9b62b55fbb | ||
|
52598599e7 | ||
|
cccc84624a | ||
|
d54921900b | ||
|
2a4bc7349c | ||
|
7b641c61cd | ||
|
3a7b05264d | ||
|
a1e3d98538 | ||
|
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 | ||
|
603e42b907 | ||
|
d51603a382 |
53
.eslintrc.js
53
.eslintrc.js
@@ -8,27 +8,28 @@ const a11yOff = Object.keys(require("eslint-plugin-jsx-a11y").rules).reduce(
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
env: {
|
env: {
|
||||||
browser: true
|
browser: true,
|
||||||
},
|
},
|
||||||
extends: [
|
extends: [
|
||||||
"airbnb",
|
"airbnb",
|
||||||
"airbnb/hooks",
|
"airbnb/hooks",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
"prettier",
|
"plugin:prettier/recommended",
|
||||||
"plugin:prettier/recommended"
|
|
||||||
],
|
],
|
||||||
ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts"],
|
ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts"],
|
||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: "./tsconfig.json",
|
project: "./tsconfig.json",
|
||||||
tsconfigRootDir: "./"
|
tsconfigRootDir: "./",
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
"import/resolver": {
|
"import/resolver": {
|
||||||
typescript: {}
|
typescript: {
|
||||||
}
|
project: "./tsconfig.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: ["@typescript-eslint", "import"],
|
plugins: ["@typescript-eslint", "import", "prettier"],
|
||||||
rules: {
|
rules: {
|
||||||
"react/jsx-uses-react": "off",
|
"react/jsx-uses-react": "off",
|
||||||
"react/react-in-jsx-scope": "off",
|
"react/react-in-jsx-scope": "off",
|
||||||
@@ -54,16 +55,44 @@ module.exports = {
|
|||||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||||
"react/jsx-filename-extension": [
|
"react/jsx-filename-extension": [
|
||||||
"error",
|
"error",
|
||||||
{ extensions: [".js", ".tsx", ".jsx"] }
|
{ extensions: [".js", ".tsx", ".jsx"] },
|
||||||
],
|
],
|
||||||
"import/extensions": [
|
"import/extensions": [
|
||||||
"error",
|
"error",
|
||||||
"ignorePackages",
|
"ignorePackages",
|
||||||
{
|
{
|
||||||
ts: "never",
|
ts: "never",
|
||||||
tsx: "never"
|
tsx: "never",
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
...a11yOff
|
"import/order": [
|
||||||
}
|
"error",
|
||||||
|
{
|
||||||
|
groups: [
|
||||||
|
"builtin",
|
||||||
|
"external",
|
||||||
|
"internal",
|
||||||
|
["sibling", "parent"],
|
||||||
|
"index",
|
||||||
|
"unknown",
|
||||||
|
],
|
||||||
|
"newlines-between": "always",
|
||||||
|
alphabetize: {
|
||||||
|
order: "asc",
|
||||||
|
caseInsensitive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"sort-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
ignoreCase: false,
|
||||||
|
ignoreDeclarationSort: true,
|
||||||
|
ignoreMemberSort: false,
|
||||||
|
memberSyntaxSortOrder: ["none", "all", "multiple", "single"],
|
||||||
|
allowSeparatedGroups: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...a11yOff,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
@@ -8,7 +8,7 @@
|
|||||||
<a href="https://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
<a href="https://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
movie-web is a web app for watching movies easily. Check it out at **[movie.squeezebox.dev](https://movie.squeezebox.dev)**.
|
movie-web is a web app for watching movies easily. Check it out at **[movie-web.app](https://movie-web.app)**.
|
||||||
|
|
||||||
This service works by displaying video files from third-party providers inside an intuitive and aesthetic user interface.
|
This service works by displaying video files from third-party providers inside an intuitive and aesthetic user interface.
|
||||||
|
|
||||||
|
@@ -29,6 +29,9 @@
|
|||||||
<!-- prevent darkreader extension from messing with our already dark site -->
|
<!-- prevent darkreader extension from messing with our already dark site -->
|
||||||
<meta name="darkreader-lock" />
|
<meta name="darkreader-lock" />
|
||||||
|
|
||||||
|
<!-- disabling referrer can fix some provider problems -->
|
||||||
|
<meta name="referrer" content="no-referrer" />
|
||||||
|
|
||||||
<title>movie-web</title>
|
<title>movie-web</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
11
package.json
11
package.json
@@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "movie-web",
|
"name": "movie-web",
|
||||||
"version": "3.0.8",
|
"version": "3.0.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "https://movie.squeezebox.dev",
|
"homepage": "https://movie-web.app",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||||
"@headlessui/react": "^1.5.0",
|
"@headlessui/react": "^1.5.0",
|
||||||
"@react-spring/web": "^9.7.1",
|
"@react-spring/web": "^9.7.1",
|
||||||
|
"@sentry/integrations": "^7.49.0",
|
||||||
|
"@sentry/react": "^7.49.0",
|
||||||
"@use-gesture/react": "^10.2.24",
|
"@use-gesture/react": "^10.2.24",
|
||||||
"core-js": "^3.29.1",
|
"core-js": "^3.29.1",
|
||||||
"crypto-js": "^4.1.1",
|
"crypto-js": "^4.1.1",
|
||||||
@@ -19,7 +21,6 @@
|
|||||||
"json5": "^2.2.0",
|
"json5": "^2.2.0",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
"nanoid": "^4.0.0",
|
"nanoid": "^4.0.0",
|
||||||
"node-webvtt": "^1.9.4",
|
|
||||||
"ofetch": "^1.0.0",
|
"ofetch": "^1.0.0",
|
||||||
"pako": "^2.1.0",
|
"pako": "^2.1.0",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
"react-stickynode": "^4.1.0",
|
"react-stickynode": "^4.1.0",
|
||||||
"react-transition-group": "^4.4.5",
|
"react-transition-group": "^4.4.5",
|
||||||
"react-use": "^17.4.0",
|
"react-use": "^17.4.0",
|
||||||
"srt-webvtt": "^2.0.0",
|
"subsrt-ts": "^2.1.0",
|
||||||
"unpacker": "^1.0.1"
|
"unpacker": "^1.0.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
"eslint-config-airbnb": "19.0.4",
|
"eslint-config-airbnb": "19.0.4",
|
||||||
"eslint-config-prettier": "^8.6.0",
|
"eslint-config-prettier": "^8.6.0",
|
||||||
"eslint-import-resolver-typescript": "^2.5.0",
|
"eslint-import-resolver-typescript": "^2.5.0",
|
||||||
"eslint-plugin-import": "^2.25.4",
|
"eslint-plugin-import": "^2.27.5",
|
||||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint-plugin-react": "7.29.4",
|
"eslint-plugin-react": "7.29.4",
|
||||||
|
27
src/@types/node_webtt.d.ts
vendored
27
src/@types/node_webtt.d.ts
vendored
@@ -1,27 +0,0 @@
|
|||||||
declare module "node-webvtt" {
|
|
||||||
interface Cue {
|
|
||||||
identifier: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
text: string;
|
|
||||||
styles: string;
|
|
||||||
}
|
|
||||||
interface Options {
|
|
||||||
meta?: boolean;
|
|
||||||
strict?: boolean;
|
|
||||||
}
|
|
||||||
type ParserError = Error;
|
|
||||||
interface ParseResult {
|
|
||||||
valid: boolean;
|
|
||||||
strict: boolean;
|
|
||||||
cues: Cue[];
|
|
||||||
errors: ParserError[];
|
|
||||||
meta?: Map<string, string>;
|
|
||||||
}
|
|
||||||
interface Segment {
|
|
||||||
duration: number;
|
|
||||||
cues: Cue[];
|
|
||||||
}
|
|
||||||
function parse(text: string, options: Options): ParseResult;
|
|
||||||
function segment(input: string, segmentLength?: number): Segment[];
|
|
||||||
}
|
|
@@ -1,9 +1,10 @@
|
|||||||
import { describe, it } from "vitest";
|
import { describe, it } from "vitest";
|
||||||
|
|
||||||
import "@/backend";
|
import "@/backend";
|
||||||
import { getProviders } from "@/backend/helpers/register";
|
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
|
||||||
import { runProvider } from "@/backend/helpers/run";
|
|
||||||
import { testData } from "@/__tests__/providers/testdata";
|
import { testData } from "@/__tests__/providers/testdata";
|
||||||
|
import { getProviders } from "@/backend/helpers/register";
|
||||||
|
import { runProvider } from "@/backend/helpers/run";
|
||||||
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
|
|
||||||
describe("providers", () => {
|
describe("providers", () => {
|
||||||
const providers = getProviders();
|
const providers = getProviders();
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
import { MWEmbedType } from "@/backend/helpers/embed";
|
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||||
|
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||||
import { registerEmbedScraper } from "@/backend/helpers/register";
|
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||||
import {
|
import {
|
||||||
|
MWEmbedStream,
|
||||||
MWStreamQuality,
|
MWStreamQuality,
|
||||||
MWStreamType,
|
MWStreamType,
|
||||||
MWEmbedStream,
|
|
||||||
} from "@/backend/helpers/streams";
|
} from "@/backend/helpers/streams";
|
||||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
|
||||||
|
|
||||||
const HOST = "streamm4u.club";
|
const HOST = "streamm4u.club";
|
||||||
const URL_BASE = `https://${HOST}`;
|
const URL_BASE = `https://${HOST}`;
|
||||||
|
@@ -1,48 +1,29 @@
|
|||||||
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
|
||||||
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
|
|
||||||
import toWebVTT from "srt-webvtt";
|
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
|
import { detect, list, parse } from "subsrt-ts";
|
||||||
|
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||||
|
|
||||||
export const sanitize = DOMPurify.sanitize;
|
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
||||||
export const CUSTOM_CAPTION_ID = "customCaption";
|
import { MWCaption } from "@/backend/helpers/streams";
|
||||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
|
||||||
if (caption.type === MWCaptionType.SRT) {
|
|
||||||
let captionBlob: Blob;
|
|
||||||
|
|
||||||
if (caption.needsProxy) {
|
export const customCaption = "external-custom";
|
||||||
captionBlob = await proxiedFetch<Blob>(caption.url, {
|
export function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
|
||||||
responseType: "blob" as any,
|
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
|
||||||
});
|
|
||||||
} 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 subtitleTypeList = list().map((type) => `.${type}`);
|
||||||
export async function convertCustomCaptionFileToWebVTT(file: File) {
|
export const sanitize = DOMPurify.sanitize;
|
||||||
const header = await file.slice(0, 6).text();
|
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||||
const isWebVTT = header === "WEBVTT";
|
if (caption.url.startsWith("blob:")) return caption.url;
|
||||||
if (!isWebVTT) {
|
let captionBlob: Blob;
|
||||||
return toWebVTT(file);
|
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) {
|
export function revokeCaptionBlob(url: string | undefined) {
|
||||||
@@ -50,3 +31,12 @@ export function revokeCaptionBlob(url: string | undefined) {
|
|||||||
URL.revokeObjectURL(url);
|
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[];
|
||||||
|
}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { conf } from "@/setup/config";
|
|
||||||
import { ofetch } from "ofetch";
|
import { ofetch } from "ofetch";
|
||||||
|
|
||||||
|
import { conf } from "@/setup/config";
|
||||||
|
|
||||||
let proxyUrlIndex = Math.floor(Math.random() * conf().PROXY_URLS.length);
|
let proxyUrlIndex = Math.floor(Math.random() * conf().PROXY_URLS.length);
|
||||||
|
|
||||||
// round robins all proxy urls
|
// round robins all proxy urls
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { DetailedMeta } from "../metadata/getmeta";
|
|
||||||
import { MWMediaType } from "../metadata/types";
|
|
||||||
import { MWEmbed } from "./embed";
|
import { MWEmbed } from "./embed";
|
||||||
import { MWStream } from "./streams";
|
import { MWStream } from "./streams";
|
||||||
|
import { DetailedMeta } from "../metadata/getmeta";
|
||||||
|
import { MWMediaType } from "../metadata/types";
|
||||||
|
|
||||||
export type MWProviderScrapeResult = {
|
export type MWProviderScrapeResult = {
|
||||||
stream?: MWStream;
|
stream?: MWStream;
|
||||||
|
@@ -6,6 +6,7 @@ export enum MWStreamType {
|
|||||||
export enum MWCaptionType {
|
export enum MWCaptionType {
|
||||||
VTT = "vtt",
|
VTT = "vtt",
|
||||||
SRT = "srt",
|
SRT = "srt",
|
||||||
|
UNKNOWN = "unknown",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MWStreamQuality {
|
export enum MWStreamQuality {
|
||||||
|
@@ -1,11 +1,12 @@
|
|||||||
import { initializeScraperStore } from "./helpers/register";
|
import { initializeScraperStore } from "./helpers/register";
|
||||||
|
|
||||||
// providers
|
// providers
|
||||||
import "./providers/gdriveplayer";
|
// import "./providers/gdriveplayer";
|
||||||
import "./providers/flixhq";
|
import "./providers/flixhq";
|
||||||
import "./providers/superstream";
|
import "./providers/superstream";
|
||||||
import "./providers/netfilm";
|
import "./providers/netfilm";
|
||||||
import "./providers/m4ufree";
|
import "./providers/m4ufree";
|
||||||
|
import "./providers/hdwatched";
|
||||||
|
|
||||||
// embeds
|
// embeds
|
||||||
import "./embeds/streamm4u";
|
import "./embeds/streamm4u";
|
||||||
|
@@ -1,13 +1,14 @@
|
|||||||
import { FetchError } from "ofetch";
|
import { FetchError } from "ofetch";
|
||||||
import { makeUrl, proxiedFetch } from "../helpers/fetch";
|
|
||||||
import {
|
import {
|
||||||
formatJWMeta,
|
|
||||||
JWMediaResult,
|
JWMediaResult,
|
||||||
JWSeasonMetaResult,
|
JWSeasonMetaResult,
|
||||||
JW_API_BASE,
|
JW_API_BASE,
|
||||||
|
formatJWMeta,
|
||||||
mediaTypeToJW,
|
mediaTypeToJW,
|
||||||
} from "./justwatch";
|
} from "./justwatch";
|
||||||
import { MWMediaMeta, MWMediaType } from "./types";
|
import { MWMediaMeta, MWMediaType } from "./types";
|
||||||
|
import { makeUrl, proxiedFetch } from "../helpers/fetch";
|
||||||
|
|
||||||
type JWExternalIdType =
|
type JWExternalIdType =
|
||||||
| "eidr"
|
| "eidr"
|
||||||
|
@@ -1,13 +1,14 @@
|
|||||||
import { SimpleCache } from "@/utils/cache";
|
import { SimpleCache } from "@/utils/cache";
|
||||||
import { proxiedFetch } from "../helpers/fetch";
|
|
||||||
import {
|
import {
|
||||||
formatJWMeta,
|
|
||||||
JWContentTypes,
|
JWContentTypes,
|
||||||
JWMediaResult,
|
JWMediaResult,
|
||||||
JW_API_BASE,
|
JW_API_BASE,
|
||||||
|
formatJWMeta,
|
||||||
mediaTypeToJW,
|
mediaTypeToJW,
|
||||||
} from "./justwatch";
|
} from "./justwatch";
|
||||||
import { MWMediaMeta, MWQuery } from "./types";
|
import { MWMediaMeta, MWQuery } from "./types";
|
||||||
|
import { proxiedFetch } from "../helpers/fetch";
|
||||||
|
|
||||||
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>();
|
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>();
|
||||||
cache.setCompare((a, b) => {
|
cache.setCompare((a, b) => {
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { compareTitle } from "@/utils/titleMatch";
|
import { compareTitle } from "@/utils/titleMatch";
|
||||||
|
|
||||||
import { proxiedFetch } from "../helpers/fetch";
|
import { proxiedFetch } from "../helpers/fetch";
|
||||||
import { registerProvider } from "../helpers/register";
|
import { registerProvider } from "../helpers/register";
|
||||||
import {
|
import {
|
||||||
@@ -8,24 +9,15 @@ import {
|
|||||||
} from "../helpers/streams";
|
} from "../helpers/streams";
|
||||||
import { MWMediaType } from "../metadata/types";
|
import { MWMediaType } from "../metadata/types";
|
||||||
|
|
||||||
// const flixHqBase = "https://api.consumet.org/movies/flixhq";
|
const flixHqBase = "https://api.consumet.org/meta/tmdb";
|
||||||
// *** TEMPORARY FIX - use other instance
|
|
||||||
// SEE ISSUE: https://github.com/consumet/api.consumet.org/issues/326
|
|
||||||
const flixHqBase = "https://c.delusionz.xyz/movies/flixhq";
|
|
||||||
|
|
||||||
|
type FlixHQMediaType = "Movie" | "TV Series";
|
||||||
interface FLIXMediaBase {
|
interface FLIXMediaBase {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
image: string;
|
image: string;
|
||||||
type: "Movie" | "TV Series";
|
type: FlixHQMediaType;
|
||||||
}
|
|
||||||
|
|
||||||
interface FLIXTVSerie extends FLIXMediaBase {
|
|
||||||
seasons: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FLIXMovie extends FLIXMediaBase {
|
|
||||||
releaseDate: string;
|
releaseDate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,12 +40,16 @@ const qualityMap: Record<string, MWStreamQuality> = {
|
|||||||
"1080": MWStreamQuality.Q1080P,
|
"1080": MWStreamQuality.Q1080P,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function flixTypeToMWType(type: FlixHQMediaType) {
|
||||||
|
if (type === "Movie") return MWMediaType.MOVIE;
|
||||||
|
return MWMediaType.SERIES;
|
||||||
|
}
|
||||||
|
|
||||||
registerProvider({
|
registerProvider({
|
||||||
id: "flixhq",
|
id: "flixhq",
|
||||||
displayName: "FlixHQ",
|
displayName: "FlixHQ",
|
||||||
rank: 100,
|
rank: 100,
|
||||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||||
|
|
||||||
async scrape({ media, episode, progress }) {
|
async scrape({ media, episode, progress }) {
|
||||||
if (!this.type.includes(media.meta.type)) {
|
if (!this.type.includes(media.meta.type)) {
|
||||||
throw new Error("Unsupported type");
|
throw new Error("Unsupported type");
|
||||||
@@ -67,60 +63,46 @@ registerProvider({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
|
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
|
||||||
if (media.meta.type === MWMediaType.MOVIE) {
|
if (v.type !== "Movie" && v.type !== "TV Series") return false;
|
||||||
if (v.type !== "Movie") return false;
|
return (
|
||||||
const movie = v as FLIXMovie;
|
compareTitle(v.title, media.meta.title) &&
|
||||||
return (
|
flixTypeToMWType(v.type) === media.meta.type &&
|
||||||
compareTitle(movie.title, media.meta.title) &&
|
v.releaseDate === media.meta.year
|
||||||
movie.releaseDate === media.meta.year
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
if (media.meta.type === MWMediaType.SERIES) {
|
|
||||||
if (v.type !== "TV Series") return false;
|
|
||||||
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 false;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!foundItem) throw new Error("No watchable item found");
|
if (!foundItem) throw new Error("No watchable item found");
|
||||||
const flixId = foundItem.id;
|
|
||||||
|
|
||||||
// get media info
|
// get media info
|
||||||
progress(25);
|
progress(25);
|
||||||
const mediaInfo = await proxiedFetch<any>("/info", {
|
const mediaInfo = await proxiedFetch<any>(`/info/${foundItem.id}`, {
|
||||||
baseURL: flixHqBase,
|
baseURL: flixHqBase,
|
||||||
params: {
|
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
|
// get stream info from media
|
||||||
progress(75);
|
progress(50);
|
||||||
|
|
||||||
// By default we assume it is a movie
|
let episodeId: string | undefined;
|
||||||
let episodeId: string | undefined = mediaInfo.episodes[0].id;
|
if (media.meta.type === MWMediaType.MOVIE) {
|
||||||
if (media.meta.type === MWMediaType.SERIES) {
|
episodeId = mediaInfo.episodeId;
|
||||||
|
} else if (media.meta.type === MWMediaType.SERIES) {
|
||||||
const seasonNo = media.meta.seasonData.number;
|
const seasonNo = media.meta.seasonData.number;
|
||||||
const episodeNo = media.meta.seasonData.episodes.find(
|
const episodeNo = media.meta.seasonData.episodes.find(
|
||||||
(e) => e.id === episode
|
(e) => e.id === episode
|
||||||
)?.number;
|
)?.number;
|
||||||
episodeId = mediaInfo.episodes.find(
|
|
||||||
(e: any) => e.season === seasonNo && e.number === episodeNo
|
const season = mediaInfo.seasons.find((o: any) => o.season === seasonNo);
|
||||||
)?.id;
|
episodeId = season.episodes.find((o: any) => o.episode === episodeNo).id;
|
||||||
}
|
}
|
||||||
if (!episodeId) throw new Error("No watchable item found");
|
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,
|
baseURL: flixHqBase,
|
||||||
params: {
|
params: {
|
||||||
episodeId,
|
id: mediaInfo.id,
|
||||||
mediaId: flixId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -1,9 +1,10 @@
|
|||||||
import { unpack } from "unpacker";
|
|
||||||
import CryptoJS from "crypto-js";
|
import CryptoJS from "crypto-js";
|
||||||
|
import { unpack } from "unpacker";
|
||||||
|
|
||||||
import { registerProvider } from "@/backend/helpers/register";
|
import { registerProvider } from "@/backend/helpers/register";
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
|
||||||
import { MWStreamQuality } from "@/backend/helpers/streams";
|
import { MWStreamQuality } from "@/backend/helpers/streams";
|
||||||
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
|
|
||||||
import { proxiedFetch } from "../helpers/fetch";
|
import { proxiedFetch } from "../helpers/fetch";
|
||||||
|
|
||||||
const format = {
|
const format = {
|
||||||
|
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: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
@@ -1,4 +1,5 @@
|
|||||||
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
|
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
|
||||||
|
|
||||||
import { proxiedFetch } from "../helpers/fetch";
|
import { proxiedFetch } from "../helpers/fetch";
|
||||||
import { registerProvider } from "../helpers/register";
|
import { registerProvider } from "../helpers/register";
|
||||||
import { MWMediaType } from "../metadata/types";
|
import { MWMediaType } from "../metadata/types";
|
||||||
|
@@ -22,6 +22,7 @@ registerProvider({
|
|||||||
displayName: "NetFilm",
|
displayName: "NetFilm",
|
||||||
rank: 15,
|
rank: 15,
|
||||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
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 }) {
|
async scrape({ media, episode, progress }) {
|
||||||
if (!this.type.includes(media.meta.type)) {
|
if (!this.type.includes(media.meta.type)) {
|
||||||
|
@@ -1,15 +1,15 @@
|
|||||||
import { registerProvider } from "@/backend/helpers/register";
|
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
|
||||||
|
|
||||||
import { customAlphabet } from "nanoid";
|
|
||||||
import CryptoJS from "crypto-js";
|
import CryptoJS from "crypto-js";
|
||||||
|
import { customAlphabet } from "nanoid";
|
||||||
|
|
||||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||||
|
import { registerProvider } from "@/backend/helpers/register";
|
||||||
import {
|
import {
|
||||||
MWCaption,
|
MWCaption,
|
||||||
MWCaptionType,
|
MWCaptionType,
|
||||||
MWStreamQuality,
|
MWStreamQuality,
|
||||||
MWStreamType,
|
MWStreamType,
|
||||||
} from "@/backend/helpers/streams";
|
} from "@/backend/helpers/streams";
|
||||||
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
import { compareTitle } from "@/utils/titleMatch";
|
import { compareTitle } from "@/utils/titleMatch";
|
||||||
|
|
||||||
const nanoid = customAlphabet("0123456789abcdef", 32);
|
const nanoid = customAlphabet("0123456789abcdef", 32);
|
||||||
@@ -225,15 +225,21 @@ registerProvider({
|
|||||||
|
|
||||||
const subtitleRes = (await get(subtitleApiQuery)).data;
|
const subtitleRes = (await get(subtitleApiQuery)).data;
|
||||||
|
|
||||||
const mappedCaptions = subtitleRes.list.map((subtitle: any): MWCaption => {
|
const mappedCaptions = subtitleRes.list.map(
|
||||||
return {
|
(subtitle: any): MWCaption | null => {
|
||||||
needsProxy: true,
|
const sub = subtitle;
|
||||||
langIso: subtitle.language,
|
sub.subtitles = subtitle.subtitles.filter((subFile: any) => {
|
||||||
url: subtitle.subtitles[0].file_path,
|
const extension = subFile.file_path.slice(-3);
|
||||||
type: MWCaptionType.SRT,
|
return [MWCaptionType.SRT, MWCaptionType.VTT].includes(extension);
|
||||||
};
|
});
|
||||||
});
|
return {
|
||||||
|
needsProxy: true,
|
||||||
|
langIso: subtitle.language,
|
||||||
|
url: sub.subtitles[0].file_path,
|
||||||
|
type: MWCaptionType.SRT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
embeds: [],
|
embeds: [],
|
||||||
stream: {
|
stream: {
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
icon?: Icons;
|
icon?: Icons;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
30
src/components/CaptionColorSelector.tsx
Normal file
30
src/components/CaptionColorSelector.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,6 +1,6 @@
|
|||||||
|
import { Listbox, Transition } from "@headlessui/react";
|
||||||
import React, { Fragment } from "react";
|
import React, { Fragment } from "react";
|
||||||
|
|
||||||
import { Listbox, Transition } from "@headlessui/react";
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
|
||||||
export interface OptionItem {
|
export interface OptionItem {
|
||||||
@@ -37,7 +37,7 @@ export function Dropdown(props: DropdownProps) {
|
|||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
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) => (
|
{props.options.map((opt) => (
|
||||||
<Listbox.Option
|
<Listbox.Option
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
|
@@ -40,6 +40,7 @@ export enum Icons {
|
|||||||
WATCH_PARTY = "watch_party",
|
WATCH_PARTY = "watch_party",
|
||||||
PICTURE_IN_PICTURE = "pictureInPicture",
|
PICTURE_IN_PICTURE = "pictureInPicture",
|
||||||
CHECKMARK = "checkmark",
|
CHECKMARK = "checkmark",
|
||||||
|
TACHOMETER = "tachometer",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IconProps {
|
export interface IconProps {
|
||||||
@@ -87,6 +88,7 @@ const iconList: Record<Icons, string> = {
|
|||||||
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>`,
|
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>`,
|
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>`,
|
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() {
|
function ChromeCastButton() {
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { Transition } from "@/components/Transition";
|
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
|
|
||||||
|
import { Transition } from "@/components/Transition";
|
||||||
|
|
||||||
export function Overlay(props: { children: React.ReactNode }) {
|
export function Overlay(props: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
|
||||||
|
|
||||||
import { DropdownButton } from "./buttons/DropdownButton";
|
import { DropdownButton } from "./buttons/DropdownButton";
|
||||||
import { Icon, Icons } from "./Icon";
|
import { Icon, Icons } from "./Icon";
|
||||||
import { TextInputControl } from "./text-inputs/TextInputControl";
|
import { TextInputControl } from "./text-inputs/TextInputControl";
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,8 +1,8 @@
|
|||||||
import { Fragment, ReactNode } from "react";
|
|
||||||
import {
|
import {
|
||||||
Transition as HeadlessTransition,
|
Transition as HeadlessTransition,
|
||||||
TransitionClasses,
|
TransitionClasses,
|
||||||
} from "@headlessui/react";
|
} from "@headlessui/react";
|
||||||
|
import { Fragment, ReactNode } from "react";
|
||||||
|
|
||||||
type TransitionAnimations =
|
type TransitionAnimations =
|
||||||
| "slide-down"
|
| "slide-down"
|
||||||
|
@@ -4,10 +4,11 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
|
|
||||||
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { BackdropContainer, useBackdrop } from "@/components/layout/Backdrop";
|
import { BackdropContainer, useBackdrop } from "@/components/layout/Backdrop";
|
||||||
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
|
|
||||||
|
import { ButtonControl, ButtonControlProps } from "./ButtonControl";
|
||||||
|
|
||||||
export interface OptionItem {
|
export interface OptionItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
|
||||||
import { ButtonControl } from "./ButtonControl";
|
import { ButtonControl } from "./ButtonControl";
|
||||||
|
|
||||||
export interface EditButtonProps {
|
export interface EditButtonProps {
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
|
|
||||||
|
import { ButtonControl, ButtonControlProps } from "./ButtonControl";
|
||||||
|
|
||||||
export interface IconButtonProps extends ButtonControlProps {
|
export interface IconButtonProps extends ButtonControlProps {
|
||||||
icon: Icons;
|
icon: Icons;
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import React, { createRef, useEffect, useState } from "react";
|
import React, { createRef, useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
import { useFade } from "@/hooks/useFade";
|
import { useFade } from "@/hooks/useFade";
|
||||||
|
|
||||||
interface BackdropProps {
|
interface BackdropProps {
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
|
||||||
export function BrandPill(props: {
|
export function BrandPill(props: {
|
||||||
|
@@ -1,10 +1,11 @@
|
|||||||
import { Component } from "react";
|
import { Component } from "react";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { Link } from "@/components/text/Link";
|
import { Link } from "@/components/text/Link";
|
||||||
import { Title } from "@/components/text/Title";
|
import { Title } from "@/components/text/Title";
|
||||||
import { conf } from "@/setup/config";
|
import { conf } from "@/setup/config";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface ErrorShowcaseProps {
|
interface ErrorShowcaseProps {
|
||||||
error: {
|
error: {
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
import { Overlay } from "@/components/Overlay";
|
|
||||||
import { Transition } from "@/components/Transition";
|
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
import { Overlay } from "@/components/Overlay";
|
||||||
|
import { Transition } from "@/components/Transition";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -35,9 +36,14 @@ export function Modal(props: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModalCard(props: { children?: ReactNode }) {
|
export function ModalCard(props: { className?: string; children?: ReactNode }) {
|
||||||
return (
|
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}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -1,9 +1,12 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { conf } from "@/setup/config";
|
|
||||||
import { useBannerSize } from "@/hooks/useBanner";
|
import { useBannerSize } from "@/hooks/useBanner";
|
||||||
|
import { conf } from "@/setup/config";
|
||||||
|
import SettingsModal from "@/views/SettingsModal";
|
||||||
|
|
||||||
import { BrandPill } from "./BrandPill";
|
import { BrandPill } from "./BrandPill";
|
||||||
|
|
||||||
export interface NavigationProps {
|
export interface NavigationProps {
|
||||||
@@ -13,7 +16,7 @@ export interface NavigationProps {
|
|||||||
|
|
||||||
export function Navigation(props: NavigationProps) {
|
export function Navigation(props: NavigationProps) {
|
||||||
const bannerHeight = useBannerSize();
|
const bannerHeight = useBannerSize();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
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 +45,14 @@ export function Navigation(props: NavigationProps) {
|
|||||||
props.children ? "hidden sm:flex" : "flex"
|
props.children ? "hidden sm:flex" : "flex"
|
||||||
} relative flex-row gap-4`}
|
} relative flex-row gap-4`}
|
||||||
>
|
>
|
||||||
|
<IconPatch
|
||||||
|
className="text-2xl text-white"
|
||||||
|
icon={Icons.GEAR}
|
||||||
|
clickable
|
||||||
|
onClick={() => {
|
||||||
|
setShowModal(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<a
|
<a
|
||||||
href={conf().DISCORD_LINK}
|
href={conf().DISCORD_LINK}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -60,6 +71,7 @@ export function Navigation(props: NavigationProps) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<SettingsModal show={showModal} onClose={() => setShowModal(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
|
||||||
interface SectionHeadingProps {
|
interface SectionHeadingProps {
|
||||||
|
@@ -1,10 +1,12 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DotList } from "@/components/text/DotList";
|
import { Link } from "react-router-dom";
|
||||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
|
||||||
import { JWMediaToId } from "@/backend/metadata/justwatch";
|
import { JWMediaToId } from "@/backend/metadata/justwatch";
|
||||||
import { Icons } from "../Icon";
|
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||||
|
import { DotList } from "@/components/text/DotList";
|
||||||
|
|
||||||
import { IconPatch } from "../buttons/IconPatch";
|
import { IconPatch } from "../buttons/IconPatch";
|
||||||
|
import { Icons } from "../Icon";
|
||||||
|
|
||||||
export interface MediaCardProps {
|
export interface MediaCardProps {
|
||||||
media: MWMediaMeta;
|
media: MWMediaMeta;
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||||
import { useWatchedContext } from "@/state/watched";
|
import { useWatchedContext } from "@/state/watched";
|
||||||
import { useMemo } from "react";
|
|
||||||
import { MediaCard } from "./MediaCard";
|
import { MediaCard } from "./MediaCard";
|
||||||
|
|
||||||
export interface WatchedMediaCardProps {
|
export interface WatchedMediaCardProps {
|
||||||
|
@@ -1,11 +1,14 @@
|
|||||||
|
import { animated, easings, useSpringValue } from "@react-spring/web";
|
||||||
|
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition";
|
import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition";
|
||||||
import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition";
|
import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition";
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
import { PopoutSection } from "@/video/components/popouts/PopoutUtils";
|
import { PopoutSection } from "@/video/components/popouts/PopoutUtils";
|
||||||
import { useSpringValue, animated, easings } from "@react-spring/web";
|
|
||||||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { Icon, Icons } from "../Icon";
|
|
||||||
import { FloatingDragHandle, MobilePopoutSpacer } from "./FloatingDragHandle";
|
import { FloatingDragHandle, MobilePopoutSpacer } from "./FloatingDragHandle";
|
||||||
|
import { Icon, Icons } from "../Icon";
|
||||||
|
|
||||||
interface FloatingCardProps {
|
interface FloatingCardProps {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -133,13 +136,15 @@ export const FloatingCardView = {
|
|||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
backText?: string;
|
backText?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
let left = (
|
let left = (
|
||||||
<div
|
<div
|
||||||
onClick={props.goBack}
|
onClick={props.goBack}
|
||||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
||||||
>
|
>
|
||||||
<Icon icon={Icons.ARROW_LEFT} />
|
<Icon icon={Icons.ARROW_LEFT} />
|
||||||
<span>{props.backText || "Go back"}</span>
|
<span>{props.backText || t("videoPlayer.popouts.back")}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (props.close)
|
if (props.close)
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import { Transition } from "@/components/Transition";
|
|
||||||
import React, {
|
import React, {
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -8,6 +7,8 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
import { Transition } from "@/components/Transition";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
import { Transition } from "@/components/Transition";
|
import { Transition } from "@/components/Transition";
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
import { ReactNode } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -29,6 +30,7 @@ export function FloatingView(props: Props) {
|
|||||||
data-floating-page={props.show ? "true" : undefined}
|
data-floating-page={props.show ? "true" : undefined}
|
||||||
style={{
|
style={{
|
||||||
height: props.height ? `${props.height}px` : undefined,
|
height: props.height ? `${props.height}px` : undefined,
|
||||||
|
maxHeight: "70vh",
|
||||||
width: props.width ? width : undefined,
|
width: props.width ? width : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor";
|
|
||||||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor";
|
||||||
|
|
||||||
interface AnchorPositionProps {
|
interface AnchorPositionProps {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
id: string;
|
id: string;
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { useSpring, animated, config } from "@react-spring/web";
|
import { animated, config, useSpring } from "@react-spring/web";
|
||||||
import { useDrag } from "@use-gesture/react";
|
import { useDrag } from "@use-gesture/react";
|
||||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
@@ -21,8 +21,20 @@ export function FloatingCardMobilePosition(props: MobilePositionProps) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const bind = useDrag(
|
const bind = useDrag(
|
||||||
({ last, velocity: [, vy], direction: [, dy], movement: [, my] }) => {
|
({
|
||||||
|
last,
|
||||||
|
velocity: [, vy],
|
||||||
|
direction: [, dy],
|
||||||
|
movement: [, my],
|
||||||
|
...event
|
||||||
|
}) => {
|
||||||
if (closing.current) return;
|
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;
|
const height = cardRect?.height ?? 0;
|
||||||
if (last) {
|
if (last) {
|
||||||
// if past half height downwards
|
// if past half height downwards
|
||||||
@@ -69,7 +81,7 @@ export function FloatingCardMobilePosition(props: MobilePositionProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
transform: `translateY(${
|
transform: `translateY(${
|
||||||
window.innerHeight - (cardRect?.height ?? 0) + 200
|
window.innerHeight - (cardRect?.height ?? 0) + 200
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { Link as LinkRouter } from "react-router-dom";
|
import { Link as LinkRouter } from "react-router-dom";
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
|
||||||
interface IArrowLinkPropsBase {
|
interface IArrowLinkPropsBase {
|
||||||
|
@@ -1,12 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
ReactNode,
|
|
||||||
createContext,
|
|
||||||
useState,
|
|
||||||
useMemo,
|
|
||||||
Dispatch,
|
Dispatch,
|
||||||
|
ReactNode,
|
||||||
SetStateAction,
|
SetStateAction,
|
||||||
useEffect,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useMeasure } from "react-use";
|
import { useMeasure } from "react-use";
|
||||||
|
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
/// <reference types="chromecast-caf-sender"/>
|
/// <reference types="chromecast-caf-sender"/>
|
||||||
|
|
||||||
import { isChromecastAvailable } from "@/setup/chromecast";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { isChromecastAvailable } from "@/setup/chromecast";
|
||||||
|
|
||||||
export function useChromecastAvailable() {
|
export function useChromecastAvailable() {
|
||||||
const [available, setAvailable] = useState<boolean | null>(null);
|
const [available, setAvailable] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { findBestStream } from "@/backend/helpers/scrape";
|
import { findBestStream } from "@/backend/helpers/scrape";
|
||||||
import { MWStream } from "@/backend/helpers/streams";
|
import { MWStream } from "@/backend/helpers/streams";
|
||||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export interface ScrapeEventLog {
|
export interface ScrapeEventLog {
|
||||||
type: "provider" | "embed";
|
type: "provider" | "embed";
|
||||||
|
@@ -1,12 +1,13 @@
|
|||||||
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { generatePath, useHistory, useRouteMatch } from "react-router-dom";
|
import { generatePath, useHistory, useRouteMatch } from "react-router-dom";
|
||||||
|
|
||||||
|
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
|
||||||
|
|
||||||
function getInitialValue(params: { type: string; query: string }) {
|
function getInitialValue(params: { type: string; query: string }) {
|
||||||
const type =
|
const type =
|
||||||
Object.values(MWMediaType).find((v) => params.type === v) ||
|
Object.values(MWMediaType).find((v) => params.type === v) ||
|
||||||
MWMediaType.MOVIE;
|
MWMediaType.MOVIE;
|
||||||
const searchQuery = params.query || "";
|
const searchQuery = decodeURIComponent(params.query || "");
|
||||||
return { type, searchQuery };
|
return { type, searchQuery };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,18 +1,19 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export function useVolumeControl(descriptor: string) {
|
export function useVolumeControl(descriptor: string) {
|
||||||
const [storedVolume, setStoredVolume] = useState(1);
|
const [storedVolume, setStoredVolume] = useState(1);
|
||||||
const controls = useControls(descriptor);
|
const controls = useControls(descriptor);
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
const mediaPlaying = useMediaPlaying(descriptor);
|
||||||
|
|
||||||
const toggleVolume = () => {
|
const toggleVolume = (isKeyboardEvent = false) => {
|
||||||
if (mediaPlaying.volume > 0) {
|
if (mediaPlaying.volume > 0) {
|
||||||
setStoredVolume(mediaPlaying.volume);
|
setStoredVolume(mediaPlaying.volume);
|
||||||
controls.setVolume(0);
|
controls.setVolume(0, isKeyboardEvent);
|
||||||
} else {
|
} else {
|
||||||
controls.setVolume(storedVolume > 0 ? storedVolume : 1);
|
controls.setVolume(storedVolume > 0 ? storedVolume : 1, isKeyboardEvent);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -3,12 +3,14 @@ import React, { Suspense } from "react";
|
|||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { BrowserRouter, HashRouter } from "react-router-dom";
|
import { BrowserRouter, HashRouter } from "react-router-dom";
|
||||||
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
|
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 { registerSW } from "virtual:pwa-register";
|
||||||
|
|
||||||
|
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
|
||||||
import App from "@/setup/App";
|
import App from "@/setup/App";
|
||||||
|
import { conf } from "@/setup/config";
|
||||||
|
|
||||||
import "@/setup/ga";
|
import "@/setup/ga";
|
||||||
|
import "@/setup/sentry";
|
||||||
import "@/setup/i18n";
|
import "@/setup/i18n";
|
||||||
import "@/setup/index.css";
|
import "@/setup/index.css";
|
||||||
import "@/backend";
|
import "@/backend";
|
||||||
|
@@ -1,20 +1,16 @@
|
|||||||
|
import { lazy } from "react";
|
||||||
import { Redirect, Route, Switch } from "react-router-dom";
|
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 { 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 { BannerContextProvider } from "@/hooks/useBanner";
|
||||||
import { Layout } from "@/setup/Layout";
|
import { Layout } from "@/setup/Layout";
|
||||||
import { TestView } from "@/views/developer/TestView";
|
import { BookmarkContextProvider } from "@/state/bookmark";
|
||||||
|
import { SettingsProvider } from "@/state/settings";
|
||||||
|
import { WatchedContextProvider } from "@/state/watched";
|
||||||
|
import { MediaView } from "@/views/media/MediaView";
|
||||||
|
import { NotFoundPage } from "@/views/notfound/NotFoundView";
|
||||||
|
import { V2MigrationView } from "@/views/other/v2Migration";
|
||||||
|
import { SearchView } from "@/views/search/SearchView";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -44,15 +40,47 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* other */}
|
{/* other */}
|
||||||
<Route exact path="/dev" component={DeveloperView} />
|
|
||||||
<Route exact path="/dev/test" component={TestView} />
|
|
||||||
<Route exact path="/dev/video" component={VideoTesterView} />
|
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
path="/dev/providers"
|
path="/dev"
|
||||||
component={ProviderTesterView}
|
component={lazy(
|
||||||
|
() => import("@/views/developer/DeveloperView")
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<Route exact path="/dev/embeds" component={EmbedTesterView} />
|
<Route
|
||||||
|
exact
|
||||||
|
path="/dev/video"
|
||||||
|
component={lazy(
|
||||||
|
() => import("@/views/developer/VideoTesterView")
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* developer routes that can abuse workers are disabled in production */}
|
||||||
|
{process.env.NODE_ENV === "development" ? (
|
||||||
|
<>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
path="/dev/test"
|
||||||
|
component={lazy(
|
||||||
|
() => import("@/views/developer/TestView")
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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} />
|
<Route path="*" component={NotFoundPage} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Banner } from "@/components/Banner";
|
import { Banner } from "@/components/Banner";
|
||||||
import { useBannerSize } from "@/hooks/useBanner";
|
import { useBannerSize } from "@/hooks/useBanner";
|
||||||
import { useIsOnline } from "@/hooks/usePing";
|
import { useIsOnline } from "@/hooks/usePing";
|
||||||
import { ReactNode } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export function Layout(props: { children: ReactNode }) {
|
export function Layout(props: { children: ReactNode }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { APP_VERSION, GITHUB_LINK, DISCORD_LINK } from "./constants";
|
import { APP_VERSION, DISCORD_LINK, GITHUB_LINK } from "./constants";
|
||||||
|
|
||||||
interface Config {
|
interface Config {
|
||||||
APP_VERSION: string;
|
APP_VERSION: string;
|
||||||
|
@@ -2,3 +2,5 @@ export const APP_VERSION = import.meta.env.PACKAGE_VERSION;
|
|||||||
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
|
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
|
||||||
export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
|
export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
|
||||||
export const GA_ID = "G-44YVXRL61C";
|
export const GA_ID = "G-44YVXRL61C";
|
||||||
|
export const SENTRY_DSN =
|
||||||
|
"https://b267ab7d52674c23af4e4e6cf2956251@o4505053491167232.ingest.sentry.io/4505053495296000";
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import ReactGA from "react-ga4";
|
import ReactGA from "react-ga4";
|
||||||
|
|
||||||
import { GA_ID } from "@/setup/constants";
|
import { GA_ID } from "@/setup/constants";
|
||||||
|
|
||||||
ReactGA.initialize([
|
ReactGA.initialize([
|
||||||
|
@@ -1,10 +1,28 @@
|
|||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
// Languages
|
// Languages
|
||||||
|
import { captionLanguages } from "./iso6391";
|
||||||
import en from "./locales/en/translation.json";
|
import en from "./locales/en/translation.json";
|
||||||
|
import fr from "./locales/fr/translation.json";
|
||||||
|
import nl from "./locales/nl/translation.json";
|
||||||
|
import tr from "./locales/tr/translation.json";
|
||||||
|
|
||||||
|
const locales = {
|
||||||
|
en: {
|
||||||
|
translation: en,
|
||||||
|
},
|
||||||
|
nl: {
|
||||||
|
translation: nl,
|
||||||
|
},
|
||||||
|
tr: {
|
||||||
|
translation: tr,
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
translation: fr,
|
||||||
|
},
|
||||||
|
};
|
||||||
i18n
|
i18n
|
||||||
// detect user language
|
// detect user language
|
||||||
// learn more: https://github.com/i18next/i18next-browser-languageDetector
|
// learn more: https://github.com/i18next/i18next-browser-languageDetector
|
||||||
@@ -15,16 +33,14 @@ i18n
|
|||||||
// for all options read: https://www.i18next.com/overview/configuration-options
|
// for all options read: https://www.i18next.com/overview/configuration-options
|
||||||
.init({
|
.init({
|
||||||
fallbackLng: "en",
|
fallbackLng: "en",
|
||||||
|
resources: locales,
|
||||||
resources: {
|
|
||||||
en: {
|
|
||||||
translation: en,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false, // not needed for react as it escapes by default
|
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;
|
export default i18n;
|
||||||
|
@@ -38,6 +38,7 @@ body[data-no-select] {
|
|||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
@@ -55,6 +56,10 @@ body[data-no-select] {
|
|||||||
@apply brightness-[500];
|
@apply brightness-[500];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.is-mobile-view .overflow-y-auto {
|
||||||
|
height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
/*generated with Input range slider CSS style generator (version 20211225)
|
/*generated with Input range slider CSS style generator (version 20211225)
|
||||||
https://toughengineer.github.io/demo/slider-styler*/
|
https://toughengineer.github.io/demo/slider-styler*/
|
||||||
:root {
|
:root {
|
||||||
@@ -62,6 +67,7 @@ https://toughengineer.github.io/demo/slider-styler*/
|
|||||||
--slider-border-radius: 1em;
|
--slider-border-radius: 1em;
|
||||||
--slider-progress-background: #8652bb;
|
--slider-progress-background: #8652bb;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type=range].styled-slider {
|
input[type=range].styled-slider {
|
||||||
height: var(--slider-height);
|
height: var(--slider-height);
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
@@ -101,7 +107,7 @@ input[type=range].styled-slider::-webkit-slider-thumb:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input[type=range].styled-slider.slider-progress::-webkit-slider-runnable-track {
|
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;
|
background: linear-gradient(var(--slider-progress-background), var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*mozilla*/
|
/*mozilla*/
|
||||||
@@ -127,7 +133,7 @@ input[type=range].styled-slider::-moz-range-thumb:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input[type=range].styled-slider.slider-progress::-moz-range-track {
|
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;
|
background: linear-gradient(var(--slider-progress-background), var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*ms*/
|
/*ms*/
|
||||||
|
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,18 +57,24 @@
|
|||||||
"backToHome": "Back to home",
|
"backToHome": "Back to home",
|
||||||
"backToHomeShort": "Back",
|
"backToHomeShort": "Back",
|
||||||
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
||||||
|
"timeLeft": "{{timeLeft}} left",
|
||||||
|
"finishAt": "Finish at {{timeFinished}}",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"episodes": "Episodes",
|
"episodes": "Episodes",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
"captions": "Captions",
|
"captions": "Captions",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"pictureInPicture": "Picture in Picture"
|
"pictureInPicture": "Picture in Picture",
|
||||||
|
"playbackSpeed": "Playback speed"
|
||||||
},
|
},
|
||||||
"popouts": {
|
"popouts": {
|
||||||
|
"back": "Go back",
|
||||||
"sources": "Sources",
|
"sources": "Sources",
|
||||||
"seasons": "Seasons",
|
"seasons": "Seasons",
|
||||||
"captions": "Captions",
|
"captions": "Captions",
|
||||||
|
"playbackSpeed": "Playback speed",
|
||||||
|
"customPlaybackSpeed": "Custom playback speed",
|
||||||
"captionPreferences": {
|
"captionPreferences": {
|
||||||
"title": "Customize",
|
"title": "Customize",
|
||||||
"delay": "Delay",
|
"delay": "Delay",
|
||||||
@@ -80,8 +86,9 @@
|
|||||||
"noCaptions": "No captions",
|
"noCaptions": "No captions",
|
||||||
"linkedCaptions": "Linked captions",
|
"linkedCaptions": "Linked captions",
|
||||||
"customCaption": "Custom caption",
|
"customCaption": "Custom caption",
|
||||||
"uploadCustomCaption": "Upload caption (SRT, VTT)",
|
"uploadCustomCaption": "Upload caption",
|
||||||
"noEmbeds": "No embeds were found for this source",
|
"noEmbeds": "No embeds were found for this source",
|
||||||
|
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}",
|
"loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}",
|
||||||
"embedsError": "Something went wrong loading the embeds for this thing that you like"
|
"embedsError": "Something went wrong loading the embeds for this thing that you like"
|
||||||
@@ -92,13 +99,19 @@
|
|||||||
"seasons": "Choose which season you want to watch",
|
"seasons": "Choose which season you want to watch",
|
||||||
"episode": "Pick an episode",
|
"episode": "Pick an episode",
|
||||||
"captions": "Choose a subtitle language",
|
"captions": "Choose a subtitle language",
|
||||||
"captionPreferences": "Make subtitles look how you want it"
|
"captionPreferences": "Make subtitles look how you want it",
|
||||||
|
"playbackSpeed": "Change the playback speed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"fatalError": "The video player encounted a fatal error, please report it to the <0>Discord server</0> or on <1>GitHub</1>."
|
"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": {
|
"v3": {
|
||||||
"newSiteTitle": "New version now released!",
|
"newSiteTitle": "New version now released!",
|
||||||
"newDomain": "https://movie-web.app",
|
"newDomain": "https://movie-web.app",
|
||||||
|
127
src/setup/locales/fr/translation.json
Normal file
127
src/setup/locales/fr/translation.json
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"movie": "Films",
|
||||||
|
"series": "Séries",
|
||||||
|
"stopEditing": "Arrêter l'édition",
|
||||||
|
"errors": {
|
||||||
|
"genericTitle": "Oups, c'est coupé !",
|
||||||
|
"failedMeta": "Impossible de charger les métadonnées",
|
||||||
|
"mediaFailed": "Nous n'avons pas réussi à récupérer le média que vous avez demandé. Veuillez vérifier votre connexion Internet et réessayer.",
|
||||||
|
"videoFailed": "Nous avons rencontré une erreur lors de la lecture de la vidéo que vous avez demandée. Si cela se reproduit, veuillez signaler le problème au serveur <0>Discord</0> ou sur <1>GitHub</1>."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"seasons": {
|
||||||
|
"seasonAndEpisode": "S{{saison}} E{{épisode}}"
|
||||||
|
},
|
||||||
|
"notFound": {
|
||||||
|
"genericTitle": "Introuvable",
|
||||||
|
"backArrow": "Retour à l'accueil",
|
||||||
|
"media": {
|
||||||
|
"title": "Impossible de trouver ce média",
|
||||||
|
"description": "Nous n'avons pas trouvé le média que vous avez demandé. Soit il a été supprimé, soit vous avez modifié l'URL."
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"title": "Ce fournisseur a été désactivé",
|
||||||
|
"description": "Nous avons eu des problèmes avec le fournisseur ou il était trop instable pour être utilisé, nous avons donc 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 trouvé la page que vous cherchez."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {
|
||||||
|
"back": "Retourner",
|
||||||
|
"sources": "Sources",
|
||||||
|
"seasons": "Saisons",
|
||||||
|
"captions": "Sous-titres",
|
||||||
|
"playbackSpeed": "Vitesse de lecture",
|
||||||
|
"customPlaybackSpeed": "Vitesse de lecture personnalisée",
|
||||||
|
"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",
|
||||||
|
"playbackSpeed": "Changer la vitesse de lecture"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"fatalError": "Le lecteur vidéo a rencontré une erreur fatale, veuillez la signaler au serveur <0>Discord</0> ou sur <1>GitHub</1>."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Paramètres",
|
||||||
|
"language": "Language",
|
||||||
|
"captionLanguage": "Langue des sous-titres"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
128
src/setup/locales/tr/translation.json
Normal file
128
src/setup/locales/tr/translation.json
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
{
|
||||||
|
"global": {
|
||||||
|
"name": "movie-web"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"loading_series": "Favori dizileriniz aranıyor...",
|
||||||
|
"loading_movie": "Favori filmleriniz aranıyor...",
|
||||||
|
"loading": "Yükleniyor...",
|
||||||
|
"allResults": "Bu kadarını bulabildik!",
|
||||||
|
"noResults": "Hiçbir şey bulamadık!",
|
||||||
|
"allFailed": "Medya bulunamadı, tekrar deneyin!",
|
||||||
|
"headingTitle": "Arama sonuçları",
|
||||||
|
"bookmarks": "Yerimleri",
|
||||||
|
"continueWatching": "İzlemeye devam edin",
|
||||||
|
"title": "Ne izlemek istersiniz?",
|
||||||
|
"placeholder": "Ne izlemek istersiniz?"
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"movie": "Film",
|
||||||
|
"series": "Dizi",
|
||||||
|
"stopEditing": "Düzenlemeyi durdur",
|
||||||
|
"errors": {
|
||||||
|
"genericTitle": "Hay aksi, bozuldu!",
|
||||||
|
"failedMeta": "Önbilgi yüklenemedi",
|
||||||
|
"mediaFailed": "İstediğiniz medyaya istek atarken hata oluştu, internet bağlantınızı kontrol edin ve tekrar deneyin.",
|
||||||
|
"videoFailed": "İstediğiniz videoyu oynatırken bir sorunla karşılaştık. Bu durum devam ederse lütfen bunu <0>Discord sunucumuza</0> veya <1>GitHub</1> üzerinden bildiriniz."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"seasons": {
|
||||||
|
"seasonAndEpisode": "S{{season}} B{{episode}}"
|
||||||
|
},
|
||||||
|
"notFound": {
|
||||||
|
"genericTitle": "Bulunamadı",
|
||||||
|
"backArrow": "Geri",
|
||||||
|
"media": {
|
||||||
|
"title": "Medya bulunamadı",
|
||||||
|
"description": "İstediğiniz medyayı bulamadık. URL'i yanlış girdiniz ya da medya kaldırıldı."
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"title": "Bu sağlayıcı devre dışı bırakıldı",
|
||||||
|
"description": "Sağlayıcı ile ilgili bir sorun oluştu ya da kullanılacak kadar stabil değildi bu yüzden devre dışı bırakmak zorunda kaldık."
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"title": "Sayfa bulunamadı",
|
||||||
|
"description": "Her yere baktık: bazanın altına, dolabın içine hatta ara sunucuya ama maalesef aradığınız sayfayı bulamadık."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"searchBar": {
|
||||||
|
"movie": "Film",
|
||||||
|
"series": "Dizi",
|
||||||
|
"Search": "Ara"
|
||||||
|
},
|
||||||
|
"videoPlayer": {
|
||||||
|
"findingBestVideo": "Sizin için en iyi videoyu buluyoruz...",
|
||||||
|
"noVideos": "Hay aksi, hiçbir video bulamadık",
|
||||||
|
"loading": "Yükleniyor...",
|
||||||
|
"backToHome": "Ana sayfaya dön",
|
||||||
|
"backToHomeShort": "Geri",
|
||||||
|
"seasonAndEpisode": "S{{season}} B{{episode}}",
|
||||||
|
"timeLeft": "{{timeLeft}} kaldı",
|
||||||
|
"finishAt": "{{timeFinished, datetime}}'de/da bitiyor",
|
||||||
|
"buttons": {
|
||||||
|
"episodes": "Bölümler",
|
||||||
|
"source": "Kaynak",
|
||||||
|
"captions": "Altyazılar",
|
||||||
|
"download": "İndir",
|
||||||
|
"settings": "Ayarlar",
|
||||||
|
"pictureInPicture": "Resim içinde Resim",
|
||||||
|
"playbackSpeed": "Oynatma Hızı"
|
||||||
|
},
|
||||||
|
"popouts": {
|
||||||
|
"back": "Geri git",
|
||||||
|
"sources": "Kaynaklar",
|
||||||
|
"seasons": "Sezonlar",
|
||||||
|
"captions": "Altyazılar",
|
||||||
|
"playbackSpeed": "Oynatma hızı",
|
||||||
|
"customPlaybackSpeed": "Özel oynatma hızı",
|
||||||
|
"captionPreferences": {
|
||||||
|
"title": "Kişiselleştirme",
|
||||||
|
"delay": "Gecikme",
|
||||||
|
"fontSize": "Boyut",
|
||||||
|
"opacity": "Opaklık",
|
||||||
|
"color": "Renk"
|
||||||
|
},
|
||||||
|
"episode": "B{{index}} - {{title}}",
|
||||||
|
"noCaptions": "Altyazı yok",
|
||||||
|
"linkedCaptions": "Kaynak Altyazıları",
|
||||||
|
"customCaption": "Özel altyazı",
|
||||||
|
"uploadCustomCaption": "Altyazı yükle",
|
||||||
|
"noEmbeds": "Bu kaynak için gömülü video bulunamadı",
|
||||||
|
|
||||||
|
"errors": {
|
||||||
|
"loadingWentWong": "{{seasonTitle}} için bölümler yüklenirken bir hata oluştu",
|
||||||
|
"embedsError": "İstediğiniz şey için gömülü video bulunurken bir hata oluştu"
|
||||||
|
},
|
||||||
|
"descriptions": {
|
||||||
|
"sources": "Hangi sağlayıcıyı kullanmak istersiniz?",
|
||||||
|
"embeds": "Görüntülemek istediğiniz videoyu seçiniz",
|
||||||
|
"seasons": "İzlemek istediğiniz sezonu seçiniz",
|
||||||
|
"episode": "Bir bölüm seçiniz",
|
||||||
|
"captions": "Altyazı dili seçiniz",
|
||||||
|
"captionPreferences": "Altyazıları istediğiniz gibi ayarlayın",
|
||||||
|
"playbackSpeed": "Oynatma hızınızı değiştirin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"fatalError": "Video oynatıcıda bir hata oluştu, lütfen bunu <0>Discord sunucumuzda</0> ya da <1>GitHub</1> üzeriden bildiriniz."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Ayarlar",
|
||||||
|
"language": "Dil",
|
||||||
|
"captionLanguage": "Altyazı Dili"
|
||||||
|
},
|
||||||
|
"v3": {
|
||||||
|
"newSiteTitle": "Yeni sürüm yayınlandı!",
|
||||||
|
"newDomain": "https://movie-web.app",
|
||||||
|
"newDomainText": "movie-web yakında yeni bir alan adına taşınacak: <0>https://movie-web.app</0>. <1>{{date}} tarihinde eski site çalışmayacağı için</1> yerimlerinizi güncellemeyi unutmayın.",
|
||||||
|
"tireless": "Bu yeni güncelleme için gece gündüz çalıştık, umarız aylardan beri hazırladığımız bu güncellemeyi beğenirsiniz.",
|
||||||
|
"leaveAnnouncement": "Götür beni!"
|
||||||
|
},
|
||||||
|
"casting": {
|
||||||
|
"casting": "Cihaza aktarılıyor..."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"offline": "İnternet bağlantınızı kontrol ediniz"
|
||||||
|
}
|
||||||
|
}
|
16
src/setup/sentry.tsx
Normal file
16
src/setup/sentry.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { CaptureConsole, HttpClient } from "@sentry/integrations";
|
||||||
|
import * as Sentry from "@sentry/react";
|
||||||
|
|
||||||
|
import { conf } from "@/setup/config";
|
||||||
|
import { SENTRY_DSN } from "@/setup/constants";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN,
|
||||||
|
release: `movie-web@${conf().APP_VERSION}`,
|
||||||
|
sampleRate: 0.5,
|
||||||
|
integrations: [
|
||||||
|
new Sentry.BrowserTracing(),
|
||||||
|
new CaptureConsole(),
|
||||||
|
new HttpClient(),
|
||||||
|
],
|
||||||
|
});
|
@@ -1,6 +1,8 @@
|
|||||||
|
import { ReactNode, createContext, useContext, useMemo } from "react";
|
||||||
|
|
||||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||||
import { useStore } from "@/utils/storage";
|
import { useStore } from "@/utils/storage";
|
||||||
import { createContext, ReactNode, useContext, useMemo } from "react";
|
|
||||||
import { BookmarkStore } from "./store";
|
import { BookmarkStore } from "./store";
|
||||||
import { BookmarkStoreData } from "./types";
|
import { BookmarkStoreData } from "./types";
|
||||||
|
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { createVersionedStore } from "@/utils/storage";
|
import { createVersionedStore } from "@/utils/storage";
|
||||||
import { migrateV1Bookmarks, OldBookmarks } from "../watched/migrations/v2";
|
|
||||||
import { BookmarkStoreData } from "./types";
|
import { BookmarkStoreData } from "./types";
|
||||||
|
import { OldBookmarks, migrateV1Bookmarks } from "../watched/migrations/v2";
|
||||||
|
|
||||||
export const BookmarkStore = createVersionedStore<BookmarkStoreData>()
|
export const BookmarkStore = createVersionedStore<BookmarkStoreData>()
|
||||||
.setKey("mw-bookmarks")
|
.setKey("mw-bookmarks")
|
||||||
|
@@ -1,14 +1,18 @@
|
|||||||
|
import { ReactNode, createContext, useContext, useMemo } from "react";
|
||||||
|
|
||||||
|
import { LangCode } from "@/setup/iso6391";
|
||||||
import { useStore } from "@/utils/storage";
|
import { useStore } from "@/utils/storage";
|
||||||
import { createContext, ReactNode, useContext, useMemo } from "react";
|
|
||||||
import { SettingsStore } from "./store";
|
import { SettingsStore } from "./store";
|
||||||
import { MWSettingsData } from "./types";
|
import { MWSettingsData } from "./types";
|
||||||
|
|
||||||
interface MWSettingsDataSetters {
|
interface MWSettingsDataSetters {
|
||||||
setLanguage(language: string): void;
|
setLanguage(language: LangCode): void;
|
||||||
|
setCaptionLanguage(language: LangCode): void;
|
||||||
setCaptionDelay(delay: number): void;
|
setCaptionDelay(delay: number): void;
|
||||||
setCaptionColor(color: string): void;
|
setCaptionColor(color: string): void;
|
||||||
setCaptionFontSize(size: number): void;
|
setCaptionFontSize(size: number): void;
|
||||||
setCaptionBackgroundColor(backgroundColor: string): void;
|
setCaptionBackgroundColor(backgroundColor: number): void;
|
||||||
}
|
}
|
||||||
type MWSettingsDataWrapper = MWSettingsData & MWSettingsDataSetters;
|
type MWSettingsDataWrapper = MWSettingsData & MWSettingsDataSetters;
|
||||||
const SettingsContext = createContext<MWSettingsDataWrapper>(null as any);
|
const SettingsContext = createContext<MWSettingsDataWrapper>(null as any);
|
||||||
@@ -17,7 +21,6 @@ export function SettingsProvider(props: { children: ReactNode }) {
|
|||||||
return Math.max(min, Math.min(value, max));
|
return Math.max(min, Math.min(value, max));
|
||||||
}
|
}
|
||||||
const [settings, setSettings] = useStore(SettingsStore);
|
const [settings, setSettings] = useStore(SettingsStore);
|
||||||
|
|
||||||
const context: MWSettingsDataWrapper = useMemo(() => {
|
const context: MWSettingsDataWrapper = useMemo(() => {
|
||||||
const settingsContext: MWSettingsDataWrapper = {
|
const settingsContext: MWSettingsDataWrapper = {
|
||||||
...settings,
|
...settings,
|
||||||
@@ -29,6 +32,14 @@ export function SettingsProvider(props: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
setCaptionLanguage(language) {
|
||||||
|
setSettings((oldSettings) => {
|
||||||
|
const captionSettings = oldSettings.captionSettings;
|
||||||
|
captionSettings.language = language;
|
||||||
|
const newSettings = oldSettings;
|
||||||
|
return newSettings;
|
||||||
|
});
|
||||||
|
},
|
||||||
setCaptionDelay(delay: number) {
|
setCaptionDelay(delay: number) {
|
||||||
setSettings((oldSettings) => {
|
setSettings((oldSettings) => {
|
||||||
const captionSettings = oldSettings.captionSettings;
|
const captionSettings = oldSettings.captionSettings;
|
||||||
@@ -56,7 +67,10 @@ export function SettingsProvider(props: { children: ReactNode }) {
|
|||||||
setCaptionBackgroundColor(backgroundColor) {
|
setCaptionBackgroundColor(backgroundColor) {
|
||||||
setSettings((oldSettings) => {
|
setSettings((oldSettings) => {
|
||||||
const style = oldSettings.captionSettings.style;
|
const style = oldSettings.captionSettings.style;
|
||||||
style.backgroundColor = backgroundColor;
|
style.backgroundColor = `${style.backgroundColor.substring(
|
||||||
|
0,
|
||||||
|
7
|
||||||
|
)}${backgroundColor.toString(16).padStart(2, "0")}`;
|
||||||
const newSettings = oldSettings;
|
const newSettings = oldSettings;
|
||||||
return newSettings;
|
return newSettings;
|
||||||
});
|
});
|
||||||
|
@@ -1,11 +1,12 @@
|
|||||||
import { createVersionedStore } from "@/utils/storage";
|
import { createVersionedStore } from "@/utils/storage";
|
||||||
import { MWSettingsData } from "./types";
|
|
||||||
|
import { MWSettingsData, MWSettingsDataV1 } from "./types";
|
||||||
|
|
||||||
export const SettingsStore = createVersionedStore<MWSettingsData>()
|
export const SettingsStore = createVersionedStore<MWSettingsData>()
|
||||||
.setKey("mw-settings")
|
.setKey("mw-settings")
|
||||||
.addVersion({
|
.addVersion({
|
||||||
version: 0,
|
version: 0,
|
||||||
create(): MWSettingsData {
|
create(): MWSettingsDataV1 {
|
||||||
return {
|
return {
|
||||||
language: "en",
|
language: "en",
|
||||||
captionSettings: {
|
captionSettings: {
|
||||||
@@ -18,5 +19,31 @@ export const SettingsStore = createVersionedStore<MWSettingsData>()
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
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();
|
.build();
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
import { LangCode } from "@/setup/iso6391";
|
||||||
|
|
||||||
export interface CaptionStyleSettings {
|
export interface CaptionStyleSettings {
|
||||||
color: string;
|
color: string;
|
||||||
/**
|
/**
|
||||||
@@ -7,7 +9,7 @@ export interface CaptionStyleSettings {
|
|||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CaptionSettings {
|
export interface CaptionSettingsV1 {
|
||||||
/**
|
/**
|
||||||
* Range is [-10, 10]s
|
* Range is [-10, 10]s
|
||||||
*/
|
*/
|
||||||
@@ -15,7 +17,20 @@ export interface CaptionSettings {
|
|||||||
style: CaptionStyleSettings;
|
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 {
|
export interface MWSettingsData {
|
||||||
language: string;
|
language: LangCode;
|
||||||
captionSettings: CaptionSettings;
|
captionSettings: CaptionSettings;
|
||||||
}
|
}
|
||||||
|
@@ -1,16 +1,18 @@
|
|||||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
|
||||||
import { useStore } from "@/utils/storage";
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
|
||||||
ReactNode,
|
ReactNode,
|
||||||
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
|
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||||
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
|
import { useStore } from "@/utils/storage";
|
||||||
|
|
||||||
import { VideoProgressStore } from "./store";
|
import { VideoProgressStore } from "./store";
|
||||||
import { StoreMediaItem, WatchedStoreItem, WatchedStoreData } from "./types";
|
import { StoreMediaItem, WatchedStoreData, WatchedStoreItem } from "./types";
|
||||||
|
|
||||||
const FIVETEEN_MINUTES = 15 * 60;
|
const FIVETEEN_MINUTES = 15 * 60;
|
||||||
const FIVE_MINUTES = 5 * 60;
|
const FIVE_MINUTES = 5 * 60;
|
||||||
|
@@ -2,6 +2,7 @@ import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
|
|||||||
import { searchForMedia } from "@/backend/metadata/search";
|
import { searchForMedia } from "@/backend/metadata/search";
|
||||||
import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types";
|
||||||
import { compareTitle } from "@/utils/titleMatch";
|
import { compareTitle } from "@/utils/titleMatch";
|
||||||
|
|
||||||
import { WatchedStoreData, WatchedStoreItem } from "../types";
|
import { WatchedStoreData, WatchedStoreItem } from "../types";
|
||||||
|
|
||||||
interface OldMediaBase {
|
interface OldMediaBase {
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import { createVersionedStore } from "@/utils/storage";
|
import { createVersionedStore } from "@/utils/storage";
|
||||||
import { migrateV2Videos, OldData } from "./migrations/v2";
|
|
||||||
|
import { OldData, migrateV2Videos } from "./migrations/v2";
|
||||||
import { WatchedStoreData } from "./types";
|
import { WatchedStoreData } from "./types";
|
||||||
|
|
||||||
export const VideoProgressStore = createVersionedStore<WatchedStoreData>()
|
export const VideoProgressStore = createVersionedStore<WatchedStoreData>()
|
||||||
|
@@ -1,36 +1,39 @@
|
|||||||
|
import { ReactNode, useCallback, useState } from "react";
|
||||||
|
|
||||||
import { Transition } from "@/components/Transition";
|
import { Transition } from "@/components/Transition";
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
import { AirplayAction } from "@/video/components/actions/AirplayAction";
|
import { AirplayAction } from "@/video/components/actions/AirplayAction";
|
||||||
import { BackdropAction } from "@/video/components/actions/BackdropAction";
|
import { BackdropAction } from "@/video/components/actions/BackdropAction";
|
||||||
|
import { CastingTextAction } from "@/video/components/actions/CastingTextAction";
|
||||||
|
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
|
||||||
import { FullscreenAction } from "@/video/components/actions/FullscreenAction";
|
import { FullscreenAction } from "@/video/components/actions/FullscreenAction";
|
||||||
import { HeaderAction } from "@/video/components/actions/HeaderAction";
|
import { HeaderAction } from "@/video/components/actions/HeaderAction";
|
||||||
|
import { KeyboardShortcutsAction } from "@/video/components/actions/KeyboardShortcutsAction";
|
||||||
import { LoadingAction } from "@/video/components/actions/LoadingAction";
|
import { LoadingAction } from "@/video/components/actions/LoadingAction";
|
||||||
import { MiddlePauseAction } from "@/video/components/actions/MiddlePauseAction";
|
import { MiddlePauseAction } from "@/video/components/actions/MiddlePauseAction";
|
||||||
import { MobileCenterAction } from "@/video/components/actions/MobileCenterAction";
|
import { MobileCenterAction } from "@/video/components/actions/MobileCenterAction";
|
||||||
import { PageTitleAction } from "@/video/components/actions/PageTitleAction";
|
import { PageTitleAction } from "@/video/components/actions/PageTitleAction";
|
||||||
import { PauseAction } from "@/video/components/actions/PauseAction";
|
import { PauseAction } from "@/video/components/actions/PauseAction";
|
||||||
|
import { PictureInPictureAction } from "@/video/components/actions/PictureInPictureAction";
|
||||||
import { ProgressAction } from "@/video/components/actions/ProgressAction";
|
import { ProgressAction } from "@/video/components/actions/ProgressAction";
|
||||||
import { SeriesSelectionAction } from "@/video/components/actions/SeriesSelectionAction";
|
import { SeriesSelectionAction } from "@/video/components/actions/SeriesSelectionAction";
|
||||||
import { ShowTitleAction } from "@/video/components/actions/ShowTitleAction";
|
import { ShowTitleAction } from "@/video/components/actions/ShowTitleAction";
|
||||||
import { KeyboardShortcutsAction } from "@/video/components/actions/KeyboardShortcutsAction";
|
|
||||||
import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction";
|
import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction";
|
||||||
import { TimeAction } from "@/video/components/actions/TimeAction";
|
import { TimeAction } from "@/video/components/actions/TimeAction";
|
||||||
import { VolumeAction } from "@/video/components/actions/VolumeAction";
|
import { VolumeAction } from "@/video/components/actions/VolumeAction";
|
||||||
import { VideoPlayerError } from "@/video/components/parts/VideoPlayerError";
|
import { VideoPlayerError } from "@/video/components/parts/VideoPlayerError";
|
||||||
|
import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderAction";
|
||||||
import {
|
import {
|
||||||
VideoPlayerBase,
|
VideoPlayerBase,
|
||||||
VideoPlayerBaseProps,
|
VideoPlayerBaseProps,
|
||||||
} from "@/video/components/VideoPlayerBase";
|
} from "@/video/components/VideoPlayerBase";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { ReactNode, useCallback, useState } from "react";
|
|
||||||
import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderAction";
|
|
||||||
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
|
|
||||||
import { CastingTextAction } from "@/video/components/actions/CastingTextAction";
|
|
||||||
import { PictureInPictureAction } from "@/video/components/actions/PictureInPictureAction";
|
|
||||||
import { CaptionRendererAction } from "./actions/CaptionRendererAction";
|
import { CaptionRendererAction } from "./actions/CaptionRendererAction";
|
||||||
import { SettingsAction } from "./actions/SettingsAction";
|
|
||||||
import { DividerAction } from "./actions/DividerAction";
|
import { DividerAction } from "./actions/DividerAction";
|
||||||
|
import { SettingsAction } from "./actions/SettingsAction";
|
||||||
|
import { VolumeAdjustedAction } from "./actions/VolumeAdjustedAction";
|
||||||
|
|
||||||
type Props = VideoPlayerBaseProps;
|
type Props = VideoPlayerBaseProps;
|
||||||
|
|
||||||
@@ -91,6 +94,7 @@ export function VideoPlayer(props: Props) {
|
|||||||
<>
|
<>
|
||||||
<KeyboardShortcutsAction />
|
<KeyboardShortcutsAction />
|
||||||
<PageTitleAction />
|
<PageTitleAction />
|
||||||
|
<VolumeAdjustedAction />
|
||||||
<VideoPlayerError onGoBack={props.onGoBack}>
|
<VideoPlayerError onGoBack={props.onGoBack}>
|
||||||
<BackdropAction onBackdropChange={onBackdropChange}>
|
<BackdropAction onBackdropChange={onBackdropChange}>
|
||||||
<CenterPosition>
|
<CenterPosition>
|
||||||
|
@@ -1,15 +1,17 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
import { CastingInternal } from "@/video/components/internal/CastingInternal";
|
import { CastingInternal } from "@/video/components/internal/CastingInternal";
|
||||||
import { WrapperRegisterInternal } from "@/video/components/internal/WrapperRegisterInternal";
|
import { WrapperRegisterInternal } from "@/video/components/internal/WrapperRegisterInternal";
|
||||||
import { VideoErrorBoundary } from "@/video/components/parts/VideoErrorBoundary";
|
import { VideoErrorBoundary } from "@/video/components/parts/VideoErrorBoundary";
|
||||||
import { useInterface } from "@/video/state/logic/interface";
|
import { useInterface } from "@/video/state/logic/interface";
|
||||||
import { useMeta } from "@/video/state/logic/meta";
|
import { useMeta } from "@/video/state/logic/meta";
|
||||||
import { useRef } from "react";
|
|
||||||
import {
|
|
||||||
useVideoPlayerDescriptor,
|
|
||||||
VideoPlayerContextProvider,
|
|
||||||
} from "../state/hooks";
|
|
||||||
import { MetaAction } from "./actions/MetaAction";
|
import { MetaAction } from "./actions/MetaAction";
|
||||||
import { VideoElementInternal } from "./internal/VideoElementInternal";
|
import { VideoElementInternal } from "./internal/VideoElementInternal";
|
||||||
|
import {
|
||||||
|
VideoPlayerContextProvider,
|
||||||
|
useVideoPlayerDescriptor,
|
||||||
|
} from "../state/hooks";
|
||||||
|
|
||||||
export interface VideoPlayerBaseProps {
|
export interface VideoPlayerBaseProps {
|
||||||
children?:
|
children?:
|
||||||
|
@@ -1,8 +1,10 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useMisc } from "@/video/state/logic/misc";
|
import { useMisc } from "@/video/state/logic/misc";
|
||||||
import { useCallback } from "react";
|
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useInterface } from "@/video/state/logic/interface";
|
import { useInterface } from "@/video/state/logic/interface";
|
||||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
interface BackdropActionProps {
|
interface BackdropActionProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -24,18 +25,16 @@ export function BackdropAction(props: BackdropActionProps) {
|
|||||||
const handleMouseMove = useCallback(() => {
|
const handleMouseMove = useCallback(() => {
|
||||||
if (!moved) {
|
if (!moved) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
// If NOT a touch, set moved to true
|
||||||
const isTouch = Date.now() - lastTouchEnd.current < 200;
|
const isTouch = Date.now() - lastTouchEnd.current < 200;
|
||||||
if (!isTouch) {
|
if (!isTouch) setMoved(true);
|
||||||
setMoved(true);
|
|
||||||
}
|
|
||||||
}, 20);
|
}, 20);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove after all
|
// remove after all
|
||||||
if (timeout.current) clearTimeout(timeout.current);
|
if (timeout.current) clearTimeout(timeout.current);
|
||||||
timeout.current = setTimeout(() => {
|
timeout.current = setTimeout(() => {
|
||||||
if (moved) setMoved(false);
|
setMoved(false);
|
||||||
timeout.current = null;
|
timeout.current = null;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}, [setMoved, moved]);
|
}, [setMoved, moved]);
|
||||||
|
@@ -1,14 +1,16 @@
|
|||||||
import { Transition } from "@/components/Transition";
|
|
||||||
import { useSettings } from "@/state/settings";
|
|
||||||
import { sanitize } from "@/backend/helpers/captions";
|
|
||||||
import { parse, Cue } from "node-webvtt";
|
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { useAsync } from "react-use";
|
import { useAsync } from "react-use";
|
||||||
|
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||||
|
|
||||||
|
import { parseSubtitles, sanitize } from "@/backend/helpers/captions";
|
||||||
|
import { Transition } from "@/components/Transition";
|
||||||
|
import { useSettings } from "@/state/settings";
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "../../state/hooks";
|
import { useVideoPlayerDescriptor } from "../../state/hooks";
|
||||||
import { useProgress } from "../../state/logic/progress";
|
import { useProgress } from "../../state/logic/progress";
|
||||||
import { useSource } from "../../state/logic/source";
|
import { useSource } from "../../state/logic/source";
|
||||||
|
|
||||||
function CaptionCue({ text }: { text?: string }) {
|
export function CaptionCue({ text, scale }: { text?: string; scale?: number }) {
|
||||||
const { captionSettings } = useSettings();
|
const { captionSettings } = useSettings();
|
||||||
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />");
|
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />");
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ function CaptionCue({ text }: { text?: string }) {
|
|||||||
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)]"
|
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={{
|
style={{
|
||||||
...captionSettings.style,
|
...captionSettings.style,
|
||||||
|
fontSize: captionSettings.style.fontSize * (scale ?? 1),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -48,16 +51,18 @@ export function CaptionRendererAction({
|
|||||||
const source = useSource(descriptor).source;
|
const source = useSource(descriptor).source;
|
||||||
const videoTime = useProgress(descriptor).time;
|
const videoTime = useProgress(descriptor).time;
|
||||||
const { captionSettings } = useSettings();
|
const { captionSettings } = useSettings();
|
||||||
const captions = useRef<Cue[]>([]);
|
const captions = useRef<ContentCaption[]>([]);
|
||||||
|
|
||||||
useAsync(async () => {
|
useAsync(async () => {
|
||||||
const url = source?.caption?.url;
|
const blobUrl = source?.caption?.url;
|
||||||
if (url) {
|
if (blobUrl) {
|
||||||
// Is there a better way?
|
const result = await fetch(blobUrl);
|
||||||
const result = await fetch(url);
|
|
||||||
// Uses UTF-8 by default
|
|
||||||
const text = await result.text();
|
const text = await result.text();
|
||||||
captions.current = parse(text, { strict: false }).cues;
|
try {
|
||||||
|
captions.current = parseSubtitles(text);
|
||||||
|
} catch (error) {
|
||||||
|
captions.current = [];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
captions.current = [];
|
captions.current = [];
|
||||||
}
|
}
|
||||||
@@ -65,8 +70,8 @@ export function CaptionRendererAction({
|
|||||||
|
|
||||||
if (!captions.current.length) return null;
|
if (!captions.current.length) return null;
|
||||||
const isVisible = (start: number, end: number): boolean => {
|
const isVisible = (start: number, end: number): boolean => {
|
||||||
const delayedStart = start + captionSettings.delay;
|
const delayedStart = start / 1000 + captionSettings.delay;
|
||||||
const delayedEnd = end + captionSettings.delay;
|
const delayedEnd = end / 1000 + captionSettings.delay;
|
||||||
return (
|
return (
|
||||||
Math.max(0, delayedStart) <= videoTime &&
|
Math.max(0, delayedStart) <= videoTime &&
|
||||||
Math.max(0, delayedEnd) >= videoTime
|
Math.max(0, delayedEnd) >= videoTime
|
||||||
@@ -82,9 +87,9 @@ export function CaptionRendererAction({
|
|||||||
show
|
show
|
||||||
>
|
>
|
||||||
{captions.current.map(
|
{captions.current.map(
|
||||||
({ identifier, end, start, text }) =>
|
({ start, end, content }) =>
|
||||||
isVisible(start, end) && (
|
isVisible(start, end) && (
|
||||||
<CaptionCue key={identifier || `${start}-${end}`} text={text} />
|
<CaptionCue key={`${start}-${end}`} text={content} />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</Transition>
|
</Transition>
|
||||||
|
@@ -1,7 +1,8 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useMisc } from "@/video/state/logic/misc";
|
import { useMisc } from "@/video/state/logic/misc";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export function CastingTextAction() {
|
export function CastingTextAction() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
|
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useMisc } from "@/video/state/logic/misc";
|
import { useMisc } from "@/video/state/logic/misc";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useMeta } from "@/video/state/logic/meta";
|
import { useMeta } from "@/video/state/logic/meta";
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
|
||||||
|
|
||||||
export function DividerAction() {
|
export function DividerAction() {
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
const descriptor = useVideoPlayerDescriptor();
|
||||||
|
@@ -1,9 +1,11 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { canFullscreen } from "@/utils/detectFeatures";
|
import { canFullscreen } from "@/utils/detectFeatures";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useInterface } from "@/video/state/logic/interface";
|
import { useInterface } from "@/video/state/logic/interface";
|
||||||
import { useCallback } from "react";
|
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { useVolumeControl } from "@/hooks/useVolumeToggle";
|
||||||
|
import { getPlayerState } from "@/video/state/cache";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useInterface } from "@/video/state/logic/interface";
|
import { useInterface } from "@/video/state/logic/interface";
|
||||||
import { getPlayerState } from "@/video/state/cache";
|
|
||||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||||
import { useProgress } from "@/video/state/logic/progress";
|
import { useProgress } from "@/video/state/logic/progress";
|
||||||
import { useVolumeControl } from "@/hooks/useVolumeToggle";
|
|
||||||
|
|
||||||
export function KeyboardShortcutsAction() {
|
export function KeyboardShortcutsAction() {
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
const descriptor = useVideoPlayerDescriptor();
|
||||||
@@ -60,7 +61,17 @@ export function KeyboardShortcutsAction() {
|
|||||||
|
|
||||||
// Mute
|
// Mute
|
||||||
case "m":
|
case "m":
|
||||||
toggleVolume();
|
toggleVolume(true);
|
||||||
|
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;
|
break;
|
||||||
|
|
||||||
// Do a barrel Roll!
|
// Do a barrel Roll!
|
||||||
|
@@ -1,9 +1,10 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { MWCaption } from "@/backend/helpers/streams";
|
import { MWCaption } from "@/backend/helpers/streams";
|
||||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useMeta } from "@/video/state/logic/meta";
|
import { useMeta } from "@/video/state/logic/meta";
|
||||||
import { useProgress } from "@/video/state/logic/progress";
|
import { useProgress } from "@/video/state/logic/progress";
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export type WindowMeta = {
|
export type WindowMeta = {
|
||||||
meta: DetailedMeta;
|
meta: DetailedMeta;
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
export function MiddlePauseAction() {
|
export function MiddlePauseAction() {
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
const descriptor = useVideoPlayerDescriptor();
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
|
|
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
|
|
||||||
import { useCurrentSeriesEpisodeInfo } from "../hooks/useCurrentSeriesEpisodeInfo";
|
import { useCurrentSeriesEpisodeInfo } from "../hooks/useCurrentSeriesEpisodeInfo";
|
||||||
|
|
||||||
export function PageTitleAction() {
|
export function PageTitleAction() {
|
||||||
|
@@ -1,8 +1,10 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||||
import { useCallback } from "react";
|
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@@ -1,13 +1,15 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import {
|
import {
|
||||||
canPictureInPicture,
|
canPictureInPicture,
|
||||||
canWebkitPictureInPicture,
|
canWebkitPictureInPicture,
|
||||||
} from "@/utils/detectFeatures";
|
} from "@/utils/detectFeatures";
|
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
|
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
makePercentage,
|
makePercentage,
|
||||||
makePercentageString,
|
makePercentageString,
|
||||||
@@ -7,7 +9,6 @@ import { getPlayerState } from "@/video/state/cache";
|
|||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useProgress } from "@/video/state/logic/progress";
|
import { useProgress } from "@/video/state/logic/progress";
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
export function ProgressAction() {
|
export function ProgressAction() {
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
const descriptor = useVideoPlayerDescriptor();
|
||||||
|
@@ -1,12 +1,13 @@
|
|||||||
import { Icons } from "@/components/Icon";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { Icons } from "@/components/Icon";
|
||||||
import { useMeta } from "@/video/state/logic/meta";
|
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
|
||||||
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
|
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
|
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useInterface } from "@/video/state/logic/interface";
|
import { useInterface } from "@/video/state/logic/interface";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useMeta } from "@/video/state/logic/meta";
|
||||||
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
@@ -1,11 +1,12 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
|
||||||
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
|
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
|
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useInterface } from "@/video/state/logic/interface";
|
import { useInterface } from "@/video/state/logic/interface";
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
|
|
||||||
import { useCurrentSeriesEpisodeInfo } from "../hooks/useCurrentSeriesEpisodeInfo";
|
import { useCurrentSeriesEpisodeInfo } from "../hooks/useCurrentSeriesEpisodeInfo";
|
||||||
|
|
||||||
export function ShowTitleAction() {
|
export function ShowTitleAction() {
|
||||||
|
@@ -2,6 +2,7 @@ import { Icons } from "@/components/Icon";
|
|||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useProgress } from "@/video/state/logic/progress";
|
import { useProgress } from "@/video/state/logic/progress";
|
||||||
|
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@@ -1,6 +1,12 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
|
import { useInterface } from "@/video/state/logic/interface";
|
||||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||||
import { useProgress } from "@/video/state/logic/progress";
|
import { useProgress } from "@/video/state/logic/progress";
|
||||||
|
import { VideoPlayerTimeFormat } from "@/video/state/types";
|
||||||
|
|
||||||
function durationExceedsHour(secs: number): boolean {
|
function durationExceedsHour(secs: number): boolean {
|
||||||
return secs > 60 * 60;
|
return secs > 60 * 60;
|
||||||
@@ -37,19 +43,71 @@ export function TimeAction(props: Props) {
|
|||||||
const descriptor = useVideoPlayerDescriptor();
|
const descriptor = useVideoPlayerDescriptor();
|
||||||
const videoTime = useProgress(descriptor);
|
const videoTime = useProgress(descriptor);
|
||||||
const mediaPlaying = useMediaPlaying(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 hasHours = durationExceedsHour(videoTime.duration);
|
||||||
const time = formatSeconds(
|
|
||||||
|
const currentTime = formatSeconds(
|
||||||
mediaPlaying.isDragSeeking ? videoTime.draggingTime : videoTime.time,
|
mediaPlaying.isDragSeeking ? videoTime.draggingTime : videoTime.time,
|
||||||
hasHours
|
hasHours
|
||||||
);
|
);
|
||||||
const duration = formatSeconds(videoTime.duration, 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 (
|
return (
|
||||||
<div className={props.className}>
|
<button
|
||||||
<p className="select-none text-white">
|
type="button"
|
||||||
{time} {props.noDuration ? "" : `/ ${duration}`}
|
className={[
|
||||||
</p>
|
"group pointer-events-auto text-white transition-transform duration-100 active:scale-110",
|
||||||
</div>
|
].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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import {
|
import {
|
||||||
makePercentage,
|
makePercentage,
|
||||||
@@ -10,7 +12,6 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
|||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useInterface } from "@/video/state/logic/interface";
|
import { useInterface } from "@/video/state/logic/interface";
|
||||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,5 +1,7 @@
|
|||||||
import { Icons } from "@/components/Icon";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Icons } from "@/components/Icon";
|
||||||
|
|
||||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@@ -1,10 +1,12 @@
|
|||||||
import { Icons } from "@/components/Icon";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
|
||||||
import { useSource } from "@/video/state/logic/source";
|
|
||||||
import { MWStreamType } from "@/backend/helpers/streams";
|
|
||||||
import { normalizeTitle } from "@/utils/normalizeTitle";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { MWStreamType } from "@/backend/helpers/streams";
|
||||||
|
import { Icons } from "@/components/Icon";
|
||||||
|
import { normalizeTitle } from "@/utils/normalizeTitle";
|
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useMeta } from "@/video/state/logic/meta";
|
import { useMeta } from "@/video/state/logic/meta";
|
||||||
|
import { useSource } from "@/video/state/logic/source";
|
||||||
|
|
||||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
||||||
|
|
||||||
export function DownloadAction() {
|
export function DownloadAction() {
|
||||||
|
@@ -0,0 +1,19 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Icons } from "@/components/Icon";
|
||||||
|
|
||||||
|
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,7 +1,9 @@
|
|||||||
import { Icons } from "@/components/Icon";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
|
||||||
|
import { Icons } from "@/components/Icon";
|
||||||
|
|
||||||
import { QualityDisplayAction } from "./QualityDisplayAction";
|
import { QualityDisplayAction } from "./QualityDisplayAction";
|
||||||
|
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClick?: () => any;
|
onClick?: () => any;
|
||||||
|
@@ -1,9 +1,10 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { MWCaption } from "@/backend/helpers/streams";
|
import { MWCaption } from "@/backend/helpers/streams";
|
||||||
import { MWSeasonWithEpisodeMeta } from "@/backend/metadata/types";
|
import { MWSeasonWithEpisodeMeta } from "@/backend/metadata/types";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { VideoPlayerMeta } from "@/video/state/types";
|
import { VideoPlayerMeta } from "@/video/state/types";
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
interface MetaControllerProps {
|
interface MetaControllerProps {
|
||||||
data?: VideoPlayerMeta;
|
data?: VideoPlayerMeta;
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user