Compare commits

...

97 Commits

Author SHA1 Message Date
mrjvs
1fd458fa27 Merge pull request #302 from movie-web/dev
Version 3.0.15
2023-05-22 19:42:58 +02:00
mrjvs
e4c15c624b Merge branch 'master' into dev 2023-05-22 19:42:50 +02:00
mrjvs
b12649bd2e Remove browser language detector, only configurable through settings now 2023-05-22 19:26:57 +02:00
mrjvs
37e10fb40e bump version 🎉 2023-05-22 19:24:45 +02:00
mrjvs
61b75da402 Merge pull request #292 from Jordaar/dev
Add 2embed provider
2023-05-22 19:22:23 +02:00
mrjvs
73b2f57fdc Merge branch 'dev' into dev 2023-05-22 19:22:00 +02:00
mrjvs
0b8c6439d7 Merge pull request #300 from thehairy/dev
fix: move meta id check to providers
2023-05-22 19:18:58 +02:00
mrjvs
4ad0d53683 Merge branch 'dev' into dev 2023-05-22 19:16:54 +02:00
mrjvs
3958df8e29 Merge pull request #301 from lem6ns/dev
refactor: use mwFetch instead of proxiedFetch
2023-05-22 19:16:03 +02:00
thehairy
fa36493c50 re-add tmdbId 2023-05-21 21:00:35 +02:00
mrjvs
efd87ab96e Merge branch 'dev' into dev 2023-05-21 20:47:16 +02:00
cloud
f80d79070e refactor: use mwFetch instead of proxiedFetch 2023-05-21 11:46:10 -07:00
mrjvs
be7b875666 Merge pull request #299 from lem6ns/dev
fix: replace consumet instance
2023-05-21 20:34:31 +02:00
thehairy
bb869fd7e3 fix: move meta id check to providers 2023-05-21 18:12:45 +02:00
cloud
2b30bb0e2b fix: replace consumet instance 2023-05-21 00:15:11 -07:00
mrjvs
b9448b5231 Merge pull request #250 from frost768/subtitle-file-type-control
Subtitle tests and type controls
2023-05-19 19:42:08 +02:00
mrjvs
7a6af6c072 remove unnecesary eslint ignore 2023-05-19 19:41:20 +02:00
frost768
2657d1f856 Merge branch 'subtitle-file-type-control' of https://github.com/frost768/movie-web into subtitle-file-type-control 2023-05-12 22:40:20 +03:00
frost768
21cc8c16d6 Merge branch 'dev' of https://github.com/frost768/movie-web into subtitle-file-type-control 2023-05-12 22:40:15 +03:00
Emre Can Minnet
b04209d9b3 Merge branch 'dev' into subtitle-file-type-control 2023-05-10 22:34:12 +03:00
James Hawkins
55bfa2be9d Merge pull request #274 from frost768/time-format
language based time formatting
2023-05-10 10:46:32 +01:00
James Hawkins
dd8b6c3f9e Merge branch 'dev' into time-format 2023-05-10 10:44:48 +01:00
James Hawkins
835e818ca0 Merge pull request #293 from zisra/fix-time
Fix "Finish at xx:xxPM/AM"
2023-05-10 10:40:17 +01:00
942725d04c Fix time finished 2023-05-09 14:07:09 -05:00
JORDAAR
010f1d3987 register 2Embed provider 2023-05-09 12:52:54 +05:30
JORDAAR
7bad6eaff9 add 2Embed provider 2023-05-09 12:52:13 +05:30
JORDAAR
bcff5a8972 add rawProxiedFetch 2023-05-09 12:51:13 +05:30
Emre Can Minnet
caba492ca2 Merge branch 'dev' into subtitle-file-type-control 2023-05-07 22:08:03 +03:00
frost768
f03145ee6d formatting added to new translation files 2023-05-06 15:03:13 +03:00
Emre Can Minnet
c0aebca4d9 Merge branch 'dev' into time-format 2023-05-06 14:42:05 +03:00
James Hawkins
c7651950ce Merge pull request #288 from zisra/deutsch
German Language
2023-05-06 10:26:17 +01:00
James Hawkins
cd3bd22a2c Update i18n.ts 2023-05-06 10:24:57 +01:00
James Hawkins
9773fcc7b5 Merge branch 'dev' into deutsch 2023-05-06 10:22:55 +01:00
James Hawkins
c937acfb09 Merge pull request #283 from raymond-nee/dev
Chinese(Simplified) Translation
2023-05-06 10:22:02 +01:00
James Hawkins
d1f3a7ad24 Merge branch 'dev' into dev 2023-05-06 10:20:24 +01:00
James Hawkins
cd0e4522c9 Merge pull request #280 from panmlg/dev
Czech language
2023-05-06 10:19:13 +01:00
f4be26d92d Deutsch! 2023-04-30 16:18:20 -05:00
frost768
22f8d8a581 Merge branch 'subtitle-file-type-control' of https://github.com/frost768/movie-web into subtitle-file-type-control 2023-05-01 00:15:59 +03:00
frost768
6cfd1235bc thumbnailCreator deleted 2023-05-01 00:15:18 +03:00
Emre Can Minnet
bdeaca3062 prefer length over falsy check
Co-authored-by: Jip Frijlink <jipfrijlink@gmail.com>
2023-05-01 00:02:14 +03:00
zisra
15e95923be English first! 2023-04-30 15:48:03 -05:00
panmlg
571df9e0ad Merge branch 'dev' into dev 2023-04-28 12:32:49 +02:00
panmlg
cce47fab5d Update src/setup/locales/cs/translation.json
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
2023-04-27 23:45:21 +02:00
Emre Can Minnet
6eb25fb49c Merge branch 'dev' into time-format 2023-04-27 22:30:29 +03:00
frost768
e61937b5c4 refactor(lint): apply lint rules 2023-04-27 22:01:14 +03:00
frost768
2338b0d652 chore: allow updates on subsrt-ts 2023-04-27 21:58:45 +03:00
frost768
37463afc8d refactor(subtitles): use official subsrt-ts package 2023-04-27 21:56:02 +03:00
frost768
9c8e89a274 lint fixes 2023-04-27 21:54:36 +03:00
frost768
bf135a2bdf Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-file-type-control 2023-04-27 21:54:24 +03:00
Monstorix
4dc6658e67 Chinese Simplified Translation 2023-04-27 00:42:23 +08:00
James Hawkins
fffc119e88 Update translation.json 2023-04-26 10:53:48 +01:00
mrjvs
5468a4677b Merge pull request #282 from movie-web/dev
v3.0.14
2023-04-25 17:47:13 +02:00
mrjvs
85cfba1a7a Merge branch 'master' into dev 2023-04-25 17:41:12 +02:00
mrjvs
fd6895c326 Merge pull request #281 from movie-web/fix-referer-maybe
Fix referer maybe
2023-04-25 17:39:11 +02:00
mrjvs
dfc3d9e50f Merge branch 'dev' into fix-referer-maybe 2023-04-25 17:38:27 +02:00
mrjvs
fcdf45d3f5 bump version for real 2023-04-25 17:37:49 +02:00
mrjvs
592837e2a6 bump version for a small release 2023-04-25 17:35:59 +02:00
mrjvs
9b3c1ffa28 add some dev routes back 2023-04-25 17:35:09 +02:00
mrjvs
7cb9ccaf14 referrer policy 2023-04-25 17:35:03 +02:00
panmlg
aa91bae418 fix of broken thingy 2023-04-25 16:59:19 +02:00
panmlg
7737bd1866 Czech language 2023-04-25 16:36:16 +02:00
mrjvs
4c0c61b0b9 Merge pull request #278 from yilmazcabuk/dev
style: sort imports according to ESLint rules
2023-04-25 00:55:41 +02:00
Yılmaz ÇABUK
4880d46dc4 style: sort imports according to ESLint rules
This commit updates the import statements in the codebase to comply with ESLint rules for import ordering. All imports have been sorted alphabetically and grouped according to the specified import groups in the ESLint configuration. This improves the codebase's consistency and maintainability.
2023-04-24 18:41:54 +03:00
frost768
ef39d87b4b update yarn.lock registry 2023-04-24 05:32:34 +03:00
frost768
e2a4caa8aa update version 2023-04-24 05:19:25 +03:00
frost768
b6a60cf5f8 patch subsrt-ts 2023-04-24 05:17:34 +03:00
frost768
f784f5f4b2 Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-file-type-control 2023-04-24 05:10:07 +03:00
Emre Can Minnet
01348f2f9a Merge branch 'dev' into time-format 2023-04-24 01:14:44 +03:00
mrjvs
8200079af7 Merge pull request #277 from movie-web/dev
V1.0.13
2023-04-24 00:14:32 +02:00
mrjvs
dcb5d2f068 Merge branch 'master' into dev 2023-04-24 00:13:41 +02:00
Jip Frijlink
99e47f16ea Bump version 2023-04-24 00:11:37 +02:00
mrjvs
6fb76908ae Merge pull request #276 from JipFr/dev
feat(player): add soundbar visibility thingie for M keyboard shortcut
2023-04-24 00:08:36 +02:00
Jip Fr
a718abdcdd feat(player): add soundbar visibility thingie for M keyboard shortcut 2023-04-24 00:00:53 +02:00
frost768
0e77d63caf Merge branch 'dev' of https://github.com/movie-web/movie-web into time-format 2023-04-23 20:15:35 +03:00
mrjvs
106290070a Merge pull request #275 from frost768/dev
Turkish translation
2023-04-23 19:13:27 +02:00
frost768
433d618096 remove relativeTime formatting 2023-04-23 20:09:50 +03:00
mrjvs
af954af36c Merge branch 'dev' into dev 2023-04-23 19:07:16 +02:00
Emre Can Minnet
16841b8e69 Merge branch 'dev' into time-format 2023-04-23 20:06:34 +03:00
James Hawkins
41979712c3 Merge pull request #272 from judemont/dev
Add French in the settings languages selector
2023-04-23 18:06:09 +01:00
frost768
9b62b55fbb Turkish translation 2023-04-23 20:03:01 +03:00
frost768
6ef41bdf1c language based time formatting 2023-04-23 20:01:12 +03:00
frost768
33ebd34808 fix multiline in subtitles 2023-04-23 18:44:16 +03:00
mrjvs
52598599e7 Merge branch 'dev' into dev 2023-04-23 16:23:31 +02:00
James Hawkins
cccc84624a Update README.md 2023-04-23 13:11:50 +01:00
mrjvs
d54921900b Update src/setup/locales/fr/translation.json
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
2023-04-23 14:11:07 +02:00
JdM
2a4bc7349c Update src/setup/locales/fr/translation.json
Co-authored-by: BrightDV <92821484+BrightDV@users.noreply.github.com>
2023-04-22 16:49:34 +02:00
JdM
7b641c61cd Update src/setup/locales/fr/translation.json
Co-authored-by: BrightDV <92821484+BrightDV@users.noreply.github.com>
2023-04-22 16:49:24 +02:00
JdM
3a7b05264d Update src/setup/locales/fr/translation.json
Co-authored-by: BrightDV <92821484+BrightDV@users.noreply.github.com>
2023-04-22 16:49:15 +02:00
JdM
a1e3d98538 Add French in the settings languages selector 2023-04-22 13:32:34 +02:00
frost768
68e5742c25 add multine test 2023-04-22 13:10:02 +03:00
Emre Can Minnet
283b9cc996 Merge branch 'dev' into subtitle-file-type-control 2023-04-22 12:52:55 +03:00
frost768
75ef831ddc Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-file-type-control 2023-04-20 22:32:42 +03:00
frost768
e2d1842946 Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-file-type-control 2023-04-20 22:29:50 +03:00
frost768
f12f53d32c Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-file-type-control 2023-04-20 22:22:10 +03:00
frost768
a910c1c18c Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-file-type-control 2023-04-17 17:49:31 +03:00
frost768
42dee51570 subtitle tests 2023-04-03 23:18:28 +03:00
frost768
9c13be37e8 subtitle type checks 2023-04-03 23:18:10 +03:00
151 changed files with 2315 additions and 968 deletions

View File

@@ -8,27 +8,28 @@ const a11yOff = Object.keys(require("eslint-plugin-jsx-a11y").rules).reduce(
module.exports = {
env: {
browser: true
browser: true,
},
extends: [
"airbnb",
"airbnb/hooks",
"plugin:@typescript-eslint/recommended",
"prettier",
"plugin:prettier/recommended"
"plugin:prettier/recommended",
],
ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: "./"
tsconfigRootDir: "./",
},
settings: {
"import/resolver": {
typescript: {}
}
typescript: {
project: "./tsconfig.json",
},
},
},
plugins: ["@typescript-eslint", "import"],
plugins: ["@typescript-eslint", "import", "prettier"],
rules: {
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
@@ -54,16 +55,44 @@ module.exports = {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"react/jsx-filename-extension": [
"error",
{ extensions: [".js", ".tsx", ".jsx"] }
{ extensions: [".js", ".tsx", ".jsx"] },
],
"import/extensions": [
"error",
"ignorePackages",
{
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,
},
};

View File

@@ -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>
</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.

View File

@@ -29,6 +29,9 @@
<!-- prevent darkreader extension from messing with our already dark site -->
<meta name="darkreader-lock" />
<!-- disabling referrer can fix some provider problems -->
<meta name="referrer" content="no-referrer" />
<title>movie-web</title>
</head>
<body>

View File

@@ -1,6 +1,6 @@
{
"name": "movie-web",
"version": "3.0.12",
"version": "3.0.15",
"private": true,
"homepage": "https://movie-web.app",
"dependencies": {
@@ -32,7 +32,7 @@
"react-stickynode": "^4.1.0",
"react-transition-group": "^4.4.5",
"react-use": "^17.4.0",
"subsrt-ts": "^2.1.0",
"subsrt-ts": "^2.1.1",
"unpacker": "^1.0.1"
},
"scripts": {
@@ -82,7 +82,7 @@
"eslint-config-airbnb": "19.0.4",
"eslint-config-prettier": "^8.6.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-prettier": "^4.2.1",
"eslint-plugin-react": "7.29.4",

View File

@@ -1,9 +1,10 @@
import { describe, it } from "vitest";
import "@/backend";
import { getProviders } from "@/backend/helpers/register";
import { MWMediaType } from "@/backend/metadata/types";
import { runProvider } from "@/backend/helpers/run";
import { testData } from "@/__tests__/providers/testdata";
import { getProviders } from "@/backend/helpers/register";
import { runProvider } from "@/backend/helpers/run";
import { MWMediaType } from "@/backend/metadata/types";
describe("providers", () => {
const providers = getProviders();

View File

@@ -0,0 +1,152 @@
import { describe, it } from "vitest";
import {
getMWCaptionTypeFromUrl,
isSupportedSubtitle,
parseSubtitles,
} from "@/backend/helpers/captions";
import { MWCaptionType } from "@/backend/helpers/streams";
import {
ass,
multilineSubtitlesTestVtt,
srt,
visibleSubtitlesTestVtt,
vtt,
} from "./testdata";
describe("subtitles", () => {
it("should return true if given url ends with a known subtitle type", ({
expect,
}) => {
expect(isSupportedSubtitle("https://example.com/test.srt")).toBe(true);
expect(isSupportedSubtitle("https://example.com/test.vtt")).toBe(true);
expect(isSupportedSubtitle("https://example.com/test.txt")).toBe(false);
});
it("should return corresponding MWCaptionType", ({ expect }) => {
expect(getMWCaptionTypeFromUrl("https://example.com/test.srt")).toBe(
MWCaptionType.SRT
);
expect(getMWCaptionTypeFromUrl("https://example.com/test.vtt")).toBe(
MWCaptionType.VTT
);
expect(getMWCaptionTypeFromUrl("https://example.com/test.txt")).toBe(
MWCaptionType.UNKNOWN
);
});
it("should throw when empty text is given", ({ expect }) => {
expect(() => parseSubtitles("")).toThrow("Given text is empty");
});
it("should parse srt", ({ expect }) => {
const parsed = parseSubtitles(srt);
const parsedSrt = [
{
type: "caption",
index: 1,
start: 0,
end: 0,
duration: 0,
content: "Test",
text: "Test",
},
{
type: "caption",
index: 2,
start: 0,
end: 0,
duration: 0,
content: "Test",
text: "Test",
},
];
expect(parsed).toHaveLength(2);
expect(parsed).toEqual(parsedSrt);
});
it("should parse vtt", ({ expect }) => {
const parsed = parseSubtitles(vtt);
const parsedVtt = [
{
type: "caption",
index: 1,
start: 0,
end: 4000,
duration: 4000,
content: "Where did he go?",
text: "Where did he go?",
},
{
type: "caption",
index: 2,
start: 3000,
end: 6500,
duration: 3500,
content: "I think he went down this lane.",
text: "I think he went down this lane.",
},
{
type: "caption",
index: 3,
start: 4000,
end: 6500,
duration: 2500,
content: "What are you waiting for?",
text: "What are you waiting for?",
},
];
expect(parsed).toHaveLength(3);
expect(parsed).toEqual(parsedVtt);
});
it("should parse ass", ({ expect }) => {
const parsed = parseSubtitles(ass);
expect(parsed).toHaveLength(3);
});
it("should delay subtitles when given a delay", ({ expect }) => {
const videoTime = 11;
let delayedSeconds = 0;
const parsed = parseSubtitles(visibleSubtitlesTestVtt);
const isVisible = (start: number, end: number, delay: number): boolean => {
const delayedStart = start / 1000 + delay;
const delayedEnd = end / 1000 + delay;
return (
Math.max(0, delayedStart) <= videoTime &&
Math.max(0, delayedEnd) >= videoTime
);
};
const visibleSubtitles = parsed.filter((c) =>
isVisible(c.start, c.end, delayedSeconds)
);
expect(visibleSubtitles).toHaveLength(1);
delayedSeconds = 10;
const delayedVisibleSubtitles = parsed.filter((c) =>
isVisible(c.start, c.end, delayedSeconds)
);
expect(delayedVisibleSubtitles).toHaveLength(1);
delayedSeconds = -10;
const delayedVisibleSubtitles2 = parsed.filter((c) =>
isVisible(c.start, c.end, delayedSeconds)
);
expect(delayedVisibleSubtitles2).toHaveLength(1);
delayedSeconds = -20;
const delayedVisibleSubtitles3 = parsed.filter((c) =>
isVisible(c.start, c.end, delayedSeconds)
);
expect(delayedVisibleSubtitles3).toHaveLength(1);
});
it("should parse multiline captions", ({ expect }) => {
const parsed = parseSubtitles(multilineSubtitlesTestVtt);
expect(parsed[0].text).toBe(`- Test 1\n- Test 2\n- Test 3`);
expect(parsed[1].text).toBe(`- Test 4`);
expect(parsed[2].text).toBe(`- Test 6`);
});
});

View File

@@ -0,0 +1,68 @@
const srt = `
1
00:00:00,000 --> 00:00:00,000
Test
2
00:00:00,000 --> 00:00:00,000
Test
`;
const vtt = `
WEBVTT
00:00:00.000 --> 00:00:04.000 position:10%,line-left align:left size:35%
Where did he go?
00:00:03.000 --> 00:00:06.500 position:90% align:right size:35%
I think he went down this lane.
00:00:04.000 --> 00:00:06.500 position:45%,line-right align:center size:35%
What are you waiting for?
`;
const ass = `[Script Info]
; Generated by Ebby.co
Title:
Original Script:
ScriptType: v4.00+
Collisions: Normal
PlayResX: 384
PlayResY: 288
PlayDepth: 0
Timer: 100.0
WrapStyle: 0
[v4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default, Arial, 16, &H00FFFFFF, &H00000000, &H00000000, &H00000000, 0, 0, 0, 0, 100, 100, 0, 0, 1, 1, 0, 2, 15, 15, 15, 0
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:10.00,0:00:20.00,Default,,0000,0000,0000,,This is the first subtitle.
Dialogue: 0,0:00:30.00,0:00:34.00,Default,,0000,0000,0000,,This is the second.
Dialogue: 0,0:00:34.00,0:00:35.00,Default,,0000,0000,0000,,Third`;
const visibleSubtitlesTestVtt = `WEBVTT
00:00:00.000 --> 00:00:10.000 position:10%,line-left align:left size:35%
Test 1
00:00:10.000 --> 00:00:20.000 position:90% align:right size:35%
Test 2
00:00:20.000 --> 00:00:31.000 position:45%,line-right align:center size:35%
Test 3
`;
const multilineSubtitlesTestVtt = `WEBVTT
00:00:00.000 --> 00:00:10.000
- Test 1\n- Test 2\n- Test 3
00:00:10.000 --> 00:00:20.000
- Test 4
00:00:20.000 --> 00:00:31.000
- Test 6
`;
export { vtt, srt, ass, visibleSubtitlesTestVtt, multilineSubtitlesTestVtt };

View File

@@ -1,11 +1,11 @@
import { MWEmbedType } from "@/backend/helpers/embed";
import { proxiedFetch } from "@/backend/helpers/fetch";
import { registerEmbedScraper } from "@/backend/helpers/register";
import {
MWEmbedStream,
MWStreamQuality,
MWStreamType,
MWEmbedStream,
} from "@/backend/helpers/streams";
import { proxiedFetch } from "@/backend/helpers/fetch";
const HOST = "streamm4u.club";
const URL_BASE = `https://${HOST}`;

View File

@@ -1,19 +1,33 @@
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
import { MWCaption } from "@/backend/helpers/streams";
import DOMPurify from "dompurify";
import { parse, detect, list } from "subsrt-ts";
import { convert, detect, list, parse } from "subsrt-ts";
import { ContentCaption } from "subsrt-ts/dist/types/handler";
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
export const customCaption = "external-custom";
export function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
}
export const subtitleTypeList = list().map((type) => `.${type}`);
export function isSupportedSubtitle(url: string): boolean {
return subtitleTypeList.some((type) => url.endsWith(type));
}
export function getMWCaptionTypeFromUrl(url: string): MWCaptionType {
if (!isSupportedSubtitle(url)) return MWCaptionType.UNKNOWN;
const type = subtitleTypeList.find((t) => url.endsWith(t));
if (!type) return MWCaptionType.UNKNOWN;
return type.slice(1) as MWCaptionType;
}
export const sanitize = DOMPurify.sanitize;
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
if (caption.url.startsWith("blob:")) return caption.url;
let captionBlob: Blob;
if (caption.needsProxy) {
if (caption.url.startsWith("blob:")) {
// custom subtitle
captionBlob = await (await fetch(caption.url)).blob();
} else if (caption.needsProxy) {
captionBlob = await proxiedFetch<Blob>(caption.url, {
responseType: "blob" as any,
});
@@ -22,7 +36,10 @@ export async function getCaptionUrl(caption: MWCaption): Promise<string> {
responseType: "blob" as any,
});
}
return URL.createObjectURL(captionBlob);
// convert to vtt for track element source which will be used in PiP mode
const text = await captionBlob.text();
const vtt = convert(text, "vtt");
return URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
}
export function revokeCaptionBlob(url: string | undefined) {
@@ -32,10 +49,14 @@ export function revokeCaptionBlob(url: string | undefined) {
}
export function parseSubtitles(text: string): ContentCaption[] {
if (detect(text) === "") {
const textTrimmed = text.trim();
if (textTrimmed === "") {
throw new Error("Given text is empty");
}
if (detect(textTrimmed) === "") {
throw new Error("Invalid subtitle format");
}
return parse(text).filter(
return parse(textTrimmed).filter(
(cue) => cue.type === "caption"
) as ContentCaption[];
}

View File

@@ -1,5 +1,6 @@
import { FetchOptions, FetchResponse, ofetch } from "ofetch";
import { conf } from "@/setup/config";
import { ofetch } from "ofetch";
let proxyUrlIndex = Math.floor(Math.random() * conf().PROXY_URLS.length);
@@ -58,3 +59,36 @@ export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
},
});
}
export function rawProxiedFetch<T>(
url: string,
ops: FetchOptions = {}
): Promise<FetchResponse<T>> {
let combinedUrl = ops?.baseURL ?? "";
if (
combinedUrl.length > 0 &&
combinedUrl.endsWith("/") &&
url.startsWith("/")
)
combinedUrl += url.slice(1);
else if (
combinedUrl.length > 0 &&
!combinedUrl.endsWith("/") &&
!url.startsWith("/")
)
combinedUrl += `/${url}`;
else combinedUrl += url;
const parsedUrl = new URL(combinedUrl);
Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
parsedUrl.searchParams.set(k, v);
});
return baseFetch.raw(getProxyUrl(), {
...ops,
baseURL: undefined,
params: {
destination: parsedUrl.toString(),
},
});
}

View File

@@ -1,7 +1,7 @@
import { DetailedMeta } from "../metadata/getmeta";
import { MWMediaType } from "../metadata/types";
import { MWEmbed } from "./embed";
import { MWStream } from "./streams";
import { DetailedMeta } from "../metadata/getmeta";
import { MWMediaType } from "../metadata/types";
export type MWProviderScrapeResult = {
stream?: MWStream;

View File

@@ -3,9 +3,16 @@ export enum MWStreamType {
HLS = "hls",
}
// subsrt-ts supported types
export enum MWCaptionType {
VTT = "vtt",
SRT = "srt",
LRC = "lrc",
SBV = "sbv",
SUB = "sub",
SSA = "ssa",
ASS = "ass",
JSON = "json",
UNKNOWN = "unknown",
}

View File

@@ -7,6 +7,7 @@ import "./providers/superstream";
import "./providers/netfilm";
import "./providers/m4ufree";
import "./providers/hdwatched";
import "./providers/2embed";
// embeds
import "./embeds/streamm4u";

View File

@@ -1,13 +1,14 @@
import { FetchError } from "ofetch";
import { makeUrl, proxiedFetch } from "../helpers/fetch";
import {
formatJWMeta,
JWMediaResult,
JWSeasonMetaResult,
JW_API_BASE,
formatJWMeta,
mediaTypeToJW,
} from "./justwatch";
import { MWMediaMeta, MWMediaType } from "./types";
import { makeUrl, proxiedFetch } from "../helpers/fetch";
type JWExternalIdType =
| "eidr"
@@ -28,8 +29,8 @@ interface JWDetailedMeta extends JWMediaResult {
export interface DetailedMeta {
meta: MWMediaMeta;
tmdbId: string;
imdbId: string;
imdbId?: string;
tmdbId?: string;
}
export async function getMetaFromId(
@@ -66,8 +67,6 @@ export async function getMetaFromId(
if (!tmdbId)
tmdbId = data.external_ids.find((v) => v.provider === "tmdb")?.external_id;
if (!imdbId || !tmdbId) throw new Error("not enough info");
let seasonData: JWSeasonMetaResult | undefined;
if (data.object_type === "show") {
const seasonToScrape = seasonId ?? data.seasons?.[0].id.toString() ?? "";

View File

@@ -1,13 +1,14 @@
import { SimpleCache } from "@/utils/cache";
import { proxiedFetch } from "../helpers/fetch";
import {
formatJWMeta,
JWContentTypes,
JWMediaResult,
JW_API_BASE,
formatJWMeta,
mediaTypeToJW,
} from "./justwatch";
import { MWMediaMeta, MWQuery } from "./types";
import { proxiedFetch } from "../helpers/fetch";
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>();
cache.setCompare((a, b) => {

View File

@@ -0,0 +1,251 @@
import Base64 from "crypto-js/enc-base64";
import Utf8 from "crypto-js/enc-utf8";
import { proxiedFetch, rawProxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import {
MWCaptionType,
MWStreamQuality,
MWStreamType,
} from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
const twoEmbedBase = "https://www.2embed.to";
async function fetchCaptchaToken(recaptchaKey: string) {
const domainHash = Base64.stringify(Utf8.parse(twoEmbedBase)).replace(
/=/g,
"."
);
const recaptchaRender = await proxiedFetch<any>(
`https://www.google.com/recaptcha/api.js?render=${recaptchaKey}`
);
const vToken = recaptchaRender.substring(
recaptchaRender.indexOf("/releases/") + 10,
recaptchaRender.indexOf("/recaptcha__en.js")
);
const recaptchaAnchor = await proxiedFetch<any>(
`https://www.google.com/recaptcha/api2/anchor?ar=1&hl=en&size=invisible&cb=flicklax&k=${recaptchaKey}&co=${domainHash}&v=${vToken}`
);
const cToken = new DOMParser()
.parseFromString(recaptchaAnchor, "text/html")
.getElementById("recaptcha-token")
?.getAttribute("value");
if (!cToken) throw new Error("Unable to find cToken");
const payload = {
v: vToken,
reason: "q",
k: recaptchaKey,
c: cToken,
sa: "",
co: twoEmbedBase,
};
const tokenData = await proxiedFetch<string>(
`https://www.google.com/recaptcha/api2/reload?${new URLSearchParams(
payload
).toString()}`,
{
headers: { referer: "https://www.google.com/recaptcha/api2/" },
method: "POST",
}
);
const token = tokenData.match('rresp","(.+?)"');
return token ? token[1] : null;
}
interface IEmbedRes {
link: string;
sources: [];
tracks: [];
type: string;
}
interface IStreamData {
status: string;
message: string;
type: string;
token: string;
result:
| {
Original: {
label: string;
file: string;
url: string;
};
}
| {
label: string;
size: number;
url: string;
}[];
}
interface ISubtitles {
url: string;
lang: string;
}
async function fetchStream(sourceId: string, captchaToken: string) {
const embedRes = await proxiedFetch<IEmbedRes>(
`${twoEmbedBase}/ajax/embed/play?id=${sourceId}&_token=${captchaToken}`,
{
headers: {
Referer: twoEmbedBase,
},
}
);
// Link format: https://rabbitstream.net/embed-4/{data-id}?z=
const rabbitStreamUrl = new URL(embedRes.link);
const dataPath = rabbitStreamUrl.pathname.split("/");
const dataId = dataPath[dataPath.length - 1];
// https://rabbitstream.net/embed/m-download/{data-id}
const download = await proxiedFetch<any>(
`${rabbitStreamUrl.origin}/embed/m-download/${dataId}`,
{
headers: {
referer: twoEmbedBase,
},
}
);
const downloadPage = new DOMParser().parseFromString(download, "text/html");
const streamlareEl = Array.from(
downloadPage.querySelectorAll(".dls-brand")
).find((el) => el.textContent?.trim() === "Streamlare");
if (!streamlareEl) throw new Error("Unable to find streamlare element");
const streamlareUrl =
streamlareEl.nextElementSibling?.querySelector("a")?.href;
if (!streamlareUrl) throw new Error("Unable to parse streamlare url");
const subtitles: ISubtitles[] = [];
const subtitlesDropdown = downloadPage.querySelectorAll(
"#user_menu .dropdown-item"
);
subtitlesDropdown.forEach((item) => {
const url = item.getAttribute("href");
const lang = item.textContent?.trim().replace("Download", "").trim();
if (url && lang) subtitles.push({ url, lang });
});
const streamlare = await proxiedFetch<any>(streamlareUrl);
const streamlarePage = new DOMParser().parseFromString(
streamlare,
"text/html"
);
const csrfToken = streamlarePage
.querySelector("head > meta:nth-child(3)")
?.getAttribute("content");
if (!csrfToken) throw new Error("Unable to find CSRF token");
const videoId = streamlareUrl.match("/[ve]/([^?#&/]+)")?.[1];
if (!videoId) throw new Error("Unable to get streamlare video id");
const streamRes = await proxiedFetch<IStreamData>(
`${new URL(streamlareUrl).origin}/api/video/download/get`,
{
method: "POST",
body: JSON.stringify({
id: videoId,
}),
headers: {
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-Token": csrfToken,
},
}
);
if (streamRes.message !== "OK") throw new Error("Unable to fetch stream");
const streamData = Array.isArray(streamRes.result)
? streamRes.result[0]
: streamRes.result.Original;
if (!streamData) throw new Error("Unable to get stream data");
const followStream = await rawProxiedFetch(streamData.url, {
method: "HEAD",
referrer: new URL(streamlareUrl).origin,
});
const finalStreamUrl = followStream.headers.get("X-Final-Destination");
return { url: finalStreamUrl, subtitles };
}
registerProvider({
id: "2embed",
displayName: "2Embed",
rank: 125,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode, progress }) {
let embedUrl = `${twoEmbedBase}/embed/tmdb/movie?id=${media.tmdbId}`;
if (media.meta.type === MWMediaType.SERIES) {
const seasonNumber = media.meta.seasonData.number;
const episodeNumber = media.meta.seasonData.episodes.find(
(e) => e.id === episode
)?.number;
embedUrl = `${twoEmbedBase}/embed/tmdb/tv?id=${media.tmdbId}&s=${seasonNumber}&e=${episodeNumber}`;
}
const embed = await proxiedFetch<any>(embedUrl);
progress(20);
const embedPage = new DOMParser().parseFromString(embed, "text/html");
const pageServerItems = Array.from(
embedPage.querySelectorAll(".item-server")
);
const pageStreamItem = pageServerItems.find((item) =>
item.textContent?.includes("Vidcloud")
);
const sourceId = pageStreamItem
? pageStreamItem.getAttribute("data-id")
: null;
if (!sourceId) throw new Error("Unable to get source id");
const siteKey = embedPage
.querySelector("body")
?.getAttribute("data-recaptcha-key");
if (!siteKey) throw new Error("Unable to get site key");
const captchaToken = await fetchCaptchaToken(siteKey);
if (!captchaToken) throw new Error("Unable to fetch captcha token");
progress(35);
const stream = await fetchStream(sourceId, captchaToken);
if (!stream.url) throw new Error("Unable to find stream url");
return {
embeds: [],
stream: {
streamUrl: stream.url,
quality: MWStreamQuality.QUNKNOWN,
type: MWStreamType.MP4,
captions: stream.subtitles.map((sub) => {
return {
langIso: sub.lang,
url: `https://cc.2cdns.com${new URL(sub.url).pathname}`,
type: MWCaptionType.VTT,
};
}),
},
};
},
});

View File

@@ -1,14 +1,15 @@
import { compareTitle } from "@/utils/titleMatch";
import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import {
MWCaptionType,
MWStreamQuality,
MWStreamType,
} from "../helpers/streams";
getMWCaptionTypeFromUrl,
isSupportedSubtitle,
} from "../helpers/captions";
import { mwFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import { MWCaption, MWStreamQuality, MWStreamType } from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
const flixHqBase = "https://api.consumet.org/meta/tmdb";
const flixHqBase = "https://consumet-api-clone.vercel.app/meta/tmdb"; // instance stolen from streaminal :)
type FlixHQMediaType = "Movie" | "TV Series";
interface FLIXMediaBase {
@@ -19,15 +20,19 @@ interface FLIXMediaBase {
type: FlixHQMediaType;
releaseDate: string;
}
function castSubtitles({ url, lang }: { url: string; lang: string }) {
interface FLIXSubType {
url: string;
lang: string;
}
function convertSubtitles({ url, lang }: FLIXSubType): MWCaption | null {
if (lang.includes("(maybe)")) return null;
const supported = isSupportedSubtitle(url);
if (!supported) return null;
const type = getMWCaptionTypeFromUrl(url);
return {
url,
langIso: lang,
type:
url.substring(url.length - 3) === "vtt"
? MWCaptionType.VTT
: MWCaptionType.SRT,
type,
};
}
@@ -54,7 +59,7 @@ registerProvider({
throw new Error("Unsupported type");
}
// search for relevant item
const searchResults = await proxiedFetch<any>(
const searchResults = await mwFetch<any>(
`/${encodeURIComponent(media.meta.title)}`,
{
baseURL: flixHqBase,
@@ -74,7 +79,7 @@ registerProvider({
// get media info
progress(25);
const mediaInfo = await proxiedFetch<any>(`/info/${foundItem.id}`, {
const mediaInfo = await mwFetch<any>(`/info/${foundItem.id}`, {
baseURL: flixHqBase,
params: {
type: flixTypeToMWType(foundItem.type),
@@ -98,7 +103,7 @@ registerProvider({
}
if (!episodeId) throw new Error("No watchable item found");
progress(75);
const watchInfo = await proxiedFetch<any>(`/watch/${episodeId}`, {
const watchInfo = await mwFetch<any>(`/watch/${episodeId}`, {
baseURL: flixHqBase,
params: {
id: mediaInfo.id,
@@ -116,11 +121,7 @@ registerProvider({
streamUrl: source.url,
quality: qualityMap[source.quality],
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
captions: watchInfo.subtitles
.filter(
(x: { url: string; lang: string }) => !x.lang.includes("(maybe)")
)
.map(castSubtitles),
captions: watchInfo.subtitles.map(convertSubtitles).filter(Boolean),
},
};
},

View File

@@ -1,9 +1,10 @@
import { unpack } from "unpacker";
import CryptoJS from "crypto-js";
import { unpack } from "unpacker";
import { registerProvider } from "@/backend/helpers/register";
import { MWMediaType } from "@/backend/metadata/types";
import { MWStreamQuality } from "@/backend/helpers/streams";
import { MWMediaType } from "@/backend/metadata/types";
import { proxiedFetch } from "../helpers/fetch";
const format = {
@@ -40,6 +41,7 @@ registerProvider({
type: [MWMediaType.MOVIE],
async scrape({ progress, media: { imdbId } }) {
if (!imdbId) throw new Error("not enough info");
progress(10);
const streamRes = await proxiedFetch<string>(
"https://database.gdriveplayer.us/player.php",

View File

@@ -123,6 +123,7 @@ registerProvider({
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape(options) {
const { media, progress } = options;
if (!media.imdbId) throw new Error("not enough info");
if (!this.type.includes(media.meta.type)) {
throw new Error("Unsupported type");
}

View File

@@ -1,4 +1,5 @@
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import { MWMediaType } from "../metadata/types";

View File

@@ -1,15 +1,19 @@
import { registerProvider } from "@/backend/helpers/register";
import { MWMediaType } from "@/backend/metadata/types";
import { customAlphabet } from "nanoid";
import CryptoJS from "crypto-js";
import { customAlphabet } from "nanoid";
import {
getMWCaptionTypeFromUrl,
isSupportedSubtitle,
} from "@/backend/helpers/captions";
import { proxiedFetch } from "@/backend/helpers/fetch";
import { registerProvider } from "@/backend/helpers/register";
import {
MWCaption,
MWCaptionType,
MWStreamQuality,
MWStreamType,
} from "@/backend/helpers/streams";
import { MWMediaType } from "@/backend/metadata/types";
import { compareTitle } from "@/utils/titleMatch";
const nanoid = customAlphabet("0123456789abcdef", 32);
@@ -111,6 +115,30 @@ const getBestQuality = (list: any[]) => {
);
};
const convertSubtitles = (subtitleGroup: any): MWCaption | null => {
let subtitles = subtitleGroup.subtitles;
subtitles = subtitles
.map((subFile: any) => {
const supported = isSupportedSubtitle(subFile.file_path);
if (!supported) return null;
const type = getMWCaptionTypeFromUrl(subFile.file_path);
return {
...subFile,
type: type as MWCaptionType,
};
})
.filter(Boolean);
if (subtitles.length === 0) return null;
const subFile = subtitles[0];
return {
needsProxy: true,
langIso: subtitleGroup.language,
url: subFile.file_path,
type: subFile.type,
};
};
registerProvider({
id: "superstream",
displayName: "Superstream",
@@ -164,16 +192,9 @@ registerProvider({
const subtitleRes = (await get(subtitleApiQuery)).data;
const mappedCaptions = subtitleRes.list.map(
(subtitle: any): MWCaption => {
return {
needsProxy: true,
langIso: subtitle.language,
url: subtitle.subtitles[0].file_path,
type: MWCaptionType.SRT,
};
}
);
const mappedCaptions = subtitleRes.list
.map(convertSubtitles)
.filter(Boolean);
return {
embeds: [],
@@ -224,22 +245,9 @@ registerProvider({
};
const subtitleRes = (await get(subtitleApiQuery)).data;
const mappedCaptions = subtitleRes.list.map(
(subtitle: any): MWCaption | null => {
const sub = subtitle;
sub.subtitles = subtitle.subtitles.filter((subFile: any) => {
const extension = subFile.file_path.slice(-3);
return [MWCaptionType.SRT, MWCaptionType.VTT].includes(extension);
});
return {
needsProxy: true,
langIso: subtitle.language,
url: sub.subtitles[0].file_path,
type: MWCaptionType.SRT,
};
}
);
const mappedCaptions = subtitleRes.list
.map(convertSubtitles)
.filter(Boolean);
return {
embeds: [],
stream: {

View File

@@ -1,6 +1,7 @@
import { Icon, Icons } from "@/components/Icon";
import { ReactNode } from "react";
import { Icon, Icons } from "@/components/Icon";
interface Props {
icon?: Icons;
onClick?: () => void;

View File

@@ -1,4 +1,5 @@
import { useSettings } from "@/state/settings";
import { Icon, Icons } from "./Icon";
export const colors = ["#ffffff", "#00ffff", "#ffff00"];

View File

@@ -1,6 +1,6 @@
import { Listbox, Transition } from "@headlessui/react";
import React, { Fragment } from "react";
import { Listbox, Transition } from "@headlessui/react";
import { Icon, Icons } from "@/components/Icon";
export interface OptionItem {
@@ -37,7 +37,7 @@ export function Dropdown(props: DropdownProps) {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<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">
<Listbox.Options className="absolute left-0 right-0 top-10 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:top-10 sm:text-sm">
{props.options.map((opt) => (
<Listbox.Option
className={({ active }) =>

View File

@@ -1,6 +1,7 @@
import { Transition } from "@/components/Transition";
import { Helmet } from "react-helmet";
import { Transition } from "@/components/Transition";
export function Overlay(props: { children: React.ReactNode }) {
return (
<>

View File

@@ -1,6 +1,8 @@
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
import { DropdownButton } from "./buttons/DropdownButton";
import { Icon, Icons } from "./Icon";
import { TextInputControl } from "./text-inputs/TextInputControl";
@@ -38,7 +40,7 @@ export function SearchBarInput(props: SearchBarProps) {
return (
<div className="relative flex flex-col rounded-[28px] bg-denim-400 transition-colors focus-within:bg-denim-400 hover:bg-denim-500 sm:flex-row sm:items-center">
<div className="pointer-events-none absolute left-5 top-0 bottom-0 flex max-h-14 items-center">
<div className="pointer-events-none absolute bottom-0 left-5 top-0 flex max-h-14 items-center">
<Icon icon={Icons.SEARCH} />
</div>
@@ -50,7 +52,7 @@ export function SearchBarInput(props: SearchBarProps) {
placeholder={props.placeholder}
/>
<div className="px-4 py-4 pt-0 sm:py-2 sm:px-2">
<div className="px-4 py-4 pt-0 sm:px-2 sm:py-2">
<DropdownButton
icon={Icons.SEARCH}
open={dropdownOpen}

View File

@@ -1,8 +1,8 @@
import { Fragment, ReactNode } from "react";
import {
Transition as HeadlessTransition,
TransitionClasses,
} from "@headlessui/react";
import { Fragment, ReactNode } from "react";
type TransitionAnimations =
| "slide-down"

View File

@@ -4,10 +4,11 @@ import React, {
useEffect,
useState,
} from "react";
import { Icon, Icons } from "@/components/Icon";
import { Icon, Icons } from "@/components/Icon";
import { BackdropContainer, useBackdrop } from "@/components/layout/Backdrop";
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
import { ButtonControl, ButtonControlProps } from "./ButtonControl";
export interface OptionItem {
id: string;

View File

@@ -1,7 +1,9 @@
import { Icon, Icons } from "@/components/Icon";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon";
import { ButtonControl } from "./ButtonControl";
export interface EditButtonProps {

View File

@@ -1,5 +1,6 @@
import { Icon, Icons } from "@/components/Icon";
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
import { ButtonControl, ButtonControlProps } from "./ButtonControl";
export interface IconButtonProps extends ButtonControlProps {
icon: Icons;

View File

@@ -1,5 +1,6 @@
import React, { createRef, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useFade } from "@/hooks/useFade";
interface BackdropProps {
@@ -99,7 +100,7 @@ export function BackdropContainer(
return (
<div ref={root}>
{createPortal(
<div className="pointer-events-none fixed top-0 left-0 z-[999]">
<div className="pointer-events-none fixed left-0 top-0 z-[999]">
<Backdrop active={props.active} {...props} />
<div ref={copy} className="pointer-events-auto absolute">
{props.children}

View File

@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon";
export function BrandPill(props: {

View File

@@ -1,10 +1,11 @@
import { Component } from "react";
import { Trans, useTranslation } from "react-i18next";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { Link } from "@/components/text/Link";
import { Title } from "@/components/text/Title";
import { conf } from "@/setup/config";
import { Trans, useTranslation } from "react-i18next";
interface ErrorShowcaseProps {
error: {

View File

@@ -1,8 +1,9 @@
import { Overlay } from "@/components/Overlay";
import { Transition } from "@/components/Transition";
import { ReactNode } from "react";
import { createPortal } from "react-dom";
import { Overlay } from "@/components/Overlay";
import { Transition } from "@/components/Transition";
interface Props {
show: boolean;
children?: ReactNode;

View File

@@ -1,10 +1,12 @@
import { ReactNode, useState } from "react";
import { Link } from "react-router-dom";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { conf } from "@/setup/config";
import { useBannerSize } from "@/hooks/useBanner";
import { conf } from "@/setup/config";
import SettingsModal from "@/views/SettingsModal";
import { BrandPill } from "./BrandPill";
export interface NavigationProps {
@@ -22,7 +24,7 @@ export function Navigation(props: NavigationProps) {
top: `${bannerHeight}px`,
}}
>
<div className="fixed left-0 right-0 flex items-center justify-between py-5 px-7">
<div className="fixed left-0 right-0 flex items-center justify-between px-7 py-5">
<div
className={`${
props.bg ? "opacity-100" : "opacity-0"

View File

@@ -1,4 +1,5 @@
import { ReactNode } from "react";
import { Icon, Icons } from "@/components/Icon";
interface SectionHeadingProps {

View File

@@ -9,12 +9,12 @@ export function Episode(props: EpisodeProps) {
return (
<div
onClick={props.onClick}
className={`transition-[background-color, transform, box-shadow] relative mr-3 mb-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded bg-denim-500 font-bold text-white hover:bg-denim-400 active:scale-110 ${
className={`transition-[background-color, transform, box-shadow] relative mb-3 mr-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded bg-denim-500 font-bold text-white hover:bg-denim-400 active:scale-110 ${
props.active ? "shadow-[inset_0_0_0_2px] shadow-bink-500" : ""
}`}
>
<div
className="absolute bottom-0 top-0 left-0 bg-bink-500 bg-opacity-50"
className="absolute bottom-0 left-0 top-0 bg-bink-500 bg-opacity-50"
style={{
width: `${props.progress || 0}%`,
}}

View File

@@ -1,10 +1,12 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { DotList } from "@/components/text/DotList";
import { MWMediaMeta } from "@/backend/metadata/types";
import { Link } from "react-router-dom";
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 { Icons } from "../Icon";
export interface MediaCardProps {
media: MWMediaMeta;
@@ -59,7 +61,7 @@ function MediaCardContent({
{series ? (
<div
className={[
"absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors",
"absolute right-2 top-2 rounded-md bg-denim-200 px-2 py-1 transition-colors",
closable ? "" : "group-hover:bg-denim-500",
].join(" ")}
>

View File

@@ -1,6 +1,8 @@
import { useMemo } from "react";
import { MWMediaMeta } from "@/backend/metadata/types";
import { useWatchedContext } from "@/state/watched";
import { useMemo } from "react";
import { MediaCard } from "./MediaCard";
export interface WatchedMediaCardProps {

View File

@@ -1,12 +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 { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition";
import { useIsMobile } from "@/hooks/useIsMobile";
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 { Icon, Icons } from "../Icon";
interface FloatingCardProps {
children?: ReactNode;
@@ -165,7 +167,7 @@ export const FloatingCardView = {
<div>{props.action ?? null}</div>
</div>
<h2 className="mt-8 mb-2 text-3xl font-bold text-white">
<h2 className="mb-2 mt-8 text-3xl font-bold text-white">
{props.title}
</h2>
<p>{props.description}</p>

View File

@@ -1,4 +1,3 @@
import { Transition } from "@/components/Transition";
import React, {
ReactNode,
useCallback,
@@ -8,6 +7,8 @@ import React, {
} from "react";
import { createPortal } from "react-dom";
import { Transition } from "@/components/Transition";
interface Props {
children?: ReactNode;
onClose?: () => void;

View File

@@ -1,6 +1,7 @@
import { ReactNode } from "react";
import { Transition } from "@/components/Transition";
import { useIsMobile } from "@/hooks/useIsMobile";
import { ReactNode } from "react";
interface Props {
children?: ReactNode;

View File

@@ -1,6 +1,7 @@
import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor";
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor";
interface AnchorPositionProps {
children?: ReactNode;
id: string;

View File

@@ -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 { ReactNode, useEffect, useRef, useState } from "react";

View File

@@ -1,4 +1,5 @@
import { Link as LinkRouter } from "react-router-dom";
import { Icon, Icons } from "@/components/Icon";
interface IArrowLinkPropsBase {

View File

@@ -1,12 +1,12 @@
import {
ReactNode,
createContext,
useState,
useMemo,
Dispatch,
ReactNode,
SetStateAction,
useEffect,
createContext,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { useMeasure } from "react-use";

View File

@@ -1,8 +1,9 @@
/// <reference types="chromecast-caf-sender"/>
import { isChromecastAvailable } from "@/setup/chromecast";
import { useEffect, useRef, useState } from "react";
import { isChromecastAvailable } from "@/setup/chromecast";
export function useChromecastAvailable() {
const [available, setAvailable] = useState<boolean | null>(null);

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { findBestStream } from "@/backend/helpers/scrape";
import { MWStream } from "@/backend/helpers/streams";
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { MWMediaType } from "@/backend/metadata/types";
import { useEffect, useState } from "react";
export interface ScrapeEventLog {
type: "provider" | "embed";

View File

@@ -1,7 +1,8 @@
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
import { useState } from "react";
import { generatePath, useHistory, useRouteMatch } from "react-router-dom";
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
function getInitialValue(params: { type: string; query: string }) {
const type =
Object.values(MWMediaType).find((v) => params.type === v) ||

View File

@@ -1,18 +1,19 @@
import { useState } from "react";
import { useControls } from "@/video/state/logic/controls";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useState } from "react";
export function useVolumeControl(descriptor: string) {
const [storedVolume, setStoredVolume] = useState(1);
const controls = useControls(descriptor);
const mediaPlaying = useMediaPlaying(descriptor);
const toggleVolume = () => {
const toggleVolume = (isKeyboardEvent = false) => {
if (mediaPlaying.volume > 0) {
setStoredVolume(mediaPlaying.volume);
controls.setVolume(0);
controls.setVolume(0, isKeyboardEvent);
} else {
controls.setVolume(storedVolume > 0 ? storedVolume : 1);
controls.setVolume(storedVolume > 0 ? storedVolume : 1, isKeyboardEvent);
}
};

View File

@@ -3,11 +3,12 @@ import React, { Suspense } from "react";
import ReactDOM from "react-dom";
import { BrowserRouter, HashRouter } from "react-router-dom";
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
import { conf } from "@/setup/config";
import { registerSW } from "virtual:pwa-register";
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
import App from "@/setup/App";
import { conf } from "@/setup/config";
import "@/setup/ga";
import "@/setup/sentry";
import "@/setup/i18n";

View File

@@ -1,16 +1,16 @@
import { lazy } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { BookmarkContextProvider } from "@/state/bookmark";
import { WatchedContextProvider } from "@/state/watched";
import { SettingsProvider } from "@/state/settings";
import { NotFoundPage } from "@/views/notfound/NotFoundView";
import { MediaView } from "@/views/media/MediaView";
import { SearchView } from "@/views/search/SearchView";
import { MWMediaType } from "@/backend/metadata/types";
import { V2MigrationView } from "@/views/other/v2Migration";
import { BannerContextProvider } from "@/hooks/useBanner";
import { Layout } from "@/setup/Layout";
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() {
return (
@@ -40,15 +40,23 @@ function App() {
/>
{/* other */}
<Route
exact
path="/dev"
component={lazy(
() => import("@/views/developer/DeveloperView")
)}
/>
<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"
component={lazy(
() => import("@/views/developer/DeveloperView")
)}
/>
<Route
exact
path="/dev/test"
@@ -56,13 +64,7 @@ function App() {
() => import("@/views/developer/TestView")
)}
/>
<Route
exact
path="/dev/video"
component={lazy(
() => import("@/views/developer/VideoTesterView")
)}
/>
<Route
exact
path="/dev/providers"

View File

@@ -1,8 +1,9 @@
import { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Banner } from "@/components/Banner";
import { useBannerSize } from "@/hooks/useBanner";
import { useIsOnline } from "@/hooks/usePing";
import { ReactNode } from "react";
import { useTranslation } from "react-i18next";
export function Layout(props: { children: ReactNode }) {
const { t } = useTranslation();

View File

@@ -1,4 +1,4 @@
import { APP_VERSION, GITHUB_LINK, DISCORD_LINK } from "./constants";
import { APP_VERSION, DISCORD_LINK, GITHUB_LINK } from "./constants";
interface Config {
APP_VERSION: string;

View File

@@ -1,4 +1,5 @@
import ReactGA from "react-ga4";
import { GA_ID } from "@/setup/constants";
ReactGA.initialize([

View File

@@ -1,11 +1,15 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
// Languages
import en from "./locales/en/translation.json";
import nl from "./locales/nl/translation.json";
import { captionLanguages } from "./iso6391";
import cs from "./locales/cs/translation.json";
import de from "./locales/de/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";
import zh from "./locales/zh/translation.json";
const locales = {
en: {
@@ -14,11 +18,23 @@ const locales = {
nl: {
translation: nl,
},
tr: {
translation: tr,
},
fr: {
translation: fr,
},
de: {
translation: de,
},
zh: {
translation: zh,
},
cs: {
translation: cs,
},
};
i18n
// detect user language
// learn more: https://github.com/i18next/i18next-browser-languageDetector
.use(LanguageDetector)
// pass the i18n instance to react-i18next.
.use(initReactI18next)
// init i18next

View File

@@ -0,0 +1,128 @@
{
"global": {
"name": "movie-web"
},
"search": {
"loading_series": "Načítání Vašich oblíbených seriálů...",
"loading_movie": "Načítání Vašich oblíbených filmů...",
"loading": "Načítání...",
"allResults": "To je vše co máme!",
"noResults": "Nemohli jsme nic najít!",
"allFailed": "Nepodařilo se najít média, zkuste to znovu!",
"headingTitle": "Výsledky vyhledávání",
"bookmarks": "Záložky",
"continueWatching": "Pokračujte ve sledování",
"title": "Co si přejete sledovat?",
"placeholder": "Co si přejete sledovat?"
},
"media": {
"movie": "Filmy",
"series": "Seriály",
"stopEditing": "Zastavit upravování",
"errors": {
"genericTitle": "Jejda, rozbilo se to!",
"failedMeta": "Nepovedlo se načíst meta",
"mediaFailed": "Nepodařilo se nám požádat o Vaše média, zkontrolujte své internetové připojení a zkuste to znovu.",
"videoFailed": "Při přehrávání požadovaného videa došlo k chybě. Pokud se tohle opakuje prosím nahlašte nám to na <0>Discord serveru</0> nebo na <1>GitHubu</1>."
}
},
"seasons": {
"seasonAndEpisode": "S{{season}} E{{episode}}"
},
"notFound": {
"genericTitle": "Nenalezeno",
"backArrow": "Zpátky domů",
"media": {
"title": "Nemohli jsme najít Vaše média.",
"description": "Nemohli jsme najít média o které jste požádali. Buďto jsme ho nemohli najít, nebo jste manipulovali s URL."
},
"provider": {
"title": "Tento poskytovatel byl zakázán",
"description": "Měli jsme s tímto poskytovatelem problémy, nebo byl moc nestabilní na používání, a tak jsme ho museli zakázat."
},
"page": {
"title": "Tuto stránku se nepodařilo najít",
"description": "Dívali jsme se všude: pod koši, ve skříni, za proxy, ale nakonec jsme nemohli najít stránku, kterou hledáte."
}
},
"searchBar": {
"movie": "Film",
"series": "Seriál",
"Search": "Hledání"
},
"videoPlayer": {
"findingBestVideo": "Hledáme pro Vás nejlepší video",
"noVideos": "Jejda, nemohli jsme žádné video najít",
"loading": "Načítání...",
"backToHome": "Zpátky domů",
"backToHomeShort": "Zpět",
"seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "Zbývá {{timeLeft}}",
"finishAt": "Končí ve {{timeFinished, datetime}}",
"buttons": {
"episodes": "Epizody",
"source": "Zdroj",
"captions": "Titulky",
"download": "Stáhnout",
"settings": "Nastavení",
"pictureInPicture": "Obraz v obraze",
"playbackSpeed": "Rychlost přehrávání"
},
"popouts": {
"back": "Zpět",
"sources": "Zdroje",
"seasons": "Sezóny",
"captions": "Titulky",
"playbackSpeed": "Rychlost přehrávání",
"customPlaybackSpeed": "Vlastní rychlost přehrávání",
"captionPreferences": {
"title": "Upravit",
"delay": "Zpoždení",
"fontSize": "Velikost",
"opacity": "Průhlednost",
"color": "Barva"
},
"episode": "E{{index}} - {{title}}",
"noCaptions": "Žádné titulky",
"linkedCaptions": "Propojené titulky",
"customCaption": "Vlastní titulky",
"uploadCustomCaption": "Nahrát titulky",
"noEmbeds": "Nebyla nalezena žádná vložení pro tento zdroj",
"errors": {
"loadingWentWong": "Něco se nepovedlo při načítání epizod pro {{seasonTitle}}",
"embedsError": "Něco se povedlo při načítání vložení pro tuhle věc, kterou máte tak rádi"
},
"descriptions": {
"sources": "Jakého poskytovatele chcete použít?",
"embeds": "Vyberte video, které chcete sledovat",
"seasons": "Vyberte sérii, kterou chcete sledovat",
"episode": "Vyberte epizodu",
"captions": "Vyberte jazyk titulků",
"captionPreferences": "Upravte titulky tak, jak se Vám budou líbit",
"playbackSpeed": "Změňtě rychlost přehrávání"
}
},
"errors": {
"fatalError": "Došlo k závažné chybě v přehrávači videa, prosím nahlašte ji na <0>Discord serveru</0> nebo na <1>GitHubu</1>."
}
},
"settings": {
"title": "Nastavení",
"language": "Jazyk",
"captionLanguage": "Jazyk titulků"
},
"v3": {
"newSiteTitle": "Je dostupná nová verze!",
"newDomain": "https://movie-web.app",
"newDomainText": "movie-web se brzy přesune na novou doménu: <0>https://movie-web.app</0>. Nezapomeňte si aktualizovat záložky, protože <1>stará stránka přestane fungovat {{date}}.</1>",
"tireless": "Pracovali jsme neúnavně na této nové aktualizaci, a tak doufáme, že se Vám bude líbit co jsme v posledních měsících kuchtili.",
"leaveAnnouncement": "Vezměte mě tam!"
},
"casting": {
"casting": "Přehrávání do zařízení..."
},
"errors": {
"offline": "Zkontrolujte své internetové připojení"
}
}

View File

@@ -0,0 +1,127 @@
{
"global": {
"name": "movie-web"
},
"search": {
"loading_series": "Auf der Suche nach Ihrer Lieblingsserie...",
"loading_movie": "Auf der Suche nach Ihren Lieblingsfilmen...",
"loading": "Wird geladen...",
"allResults": "Das ist alles, was wir haben!",
"noResults": "Wir haben nichts gefunden!",
"allFailed": "Das Medium wurde nicht gefunden, bitte versuchen Sie es erneut!",
"headingTitle": "Suchergebnisse",
"bookmarks": "Favoriten",
"continueWatching": "Weiter ansehen",
"title": "Was willst du sehen?",
"placeholder": "Was willst du sehen?"
},
"media": {
"movie": "Filme",
"series": "Serie",
"stopEditing": "Beenden Sie die Bearbeitung",
"errors": {
"genericTitle": "Hoppla, etwas ist falsch gegangen!",
"failedMeta": "Metadaten konnten nicht geladen werden",
"mediaFailed": "Wir konnten die angeforderten Medien nicht abrufen.",
"videoFailed": "Beim Abspielen des angeforderten Videos ist ein Fehler aufgetreten. <0>Discord</0> Oder weiter <1>GitHub</1>."
}
},
"seasons": {
"seasonAndEpisode": "S{{season}} E{{episode}}"
},
"notFound": {
"genericTitle": "Nicht gefunden",
"backArrow": "Zurück zur Startseite",
"media": {
"title": "Das Medium konnte nicht gefunden werden",
"description": "Wir konnten die angeforderten Medien nicht finden."
},
"provider": {
"title": "Dieser Anbieter wurde deaktiviert",
"description": "Wir hatten Probleme mit dem Anbieter oder er war zu instabil, sodass wir ihn deaktivieren mussten."
},
"page": {
"title": "Diese Seite kann nicht gefunden werden",
"description": "Wir haben überall gesucht, aber am Ende konnten wir die gesuchte Seite nicht finden."
}
},
"searchBar": {
"movie": "Film",
"series": "Serie",
"Search": "Forschen"
},
"videoPlayer": {
"findingBestVideo": "Auf der Suche nach dem besten Video für Sie",
"noVideos": "Entschuldigung, wir konnten keine Videos für Sie finden",
"loading": "Wird geladen...",
"backToHome": "Zurück zur Startseite",
"backToHomeShort": "Rückmeldung",
"seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} bleibt",
"finishAt": "Ende um {{timeFinished, datetime}}",
"buttons": {
"episodes": "Folgen",
"source": "Quelle",
"captions": "Untertitel",
"download": "Herunterladen",
"settings": "Einstellungen",
"pictureInPicture": "Bild-im-Bild",
"playbackSpeed": "Wiedergabegeschwindigkeit"
},
"popouts": {
"back": "Zurück",
"sources": "Quellen",
"seasons": "Saison",
"captions": "Untertitel",
"playbackSpeed": "Lesegeschwindigkeit",
"customPlaybackSpeed": "Benutzerdefinierte Wiedergabegeschwindigkeit",
"captionPreferences": {
"title": "Personifizieren",
"delay": "Zeitlimit",
"fontSize": "Größe",
"opacity": "Opazität",
"color": "Farbe"
},
"episode": "E{{index}} - {{title}}",
"noCaptions": "Keine Untertitel",
"linkedCaptions": "Verbundene Untertitel",
"customCaption": "Benutzerdefinierte Untertitel",
"uploadCustomCaption": "Untertitel hochladen",
"noEmbeds": "Für diese Quelle wurde kein eingebetteter Inhalt gefunden",
"errors": {
"loadingWentWong": "Beim Laden der Folgen für {{seasonTitle}} ist ein Problem aufgetreten ",
"embedsError": "Beim Laden der eingebetteter Medien ist ein Problem aufgetreten"
},
"descriptions": {
"sources": "Welchen Anbieter möchten Sie nutzen?",
"embeds": "Wählen Sie das Video aus, das Sie ansehen möchten",
"seasons": "Wählen Sie die Staffel aus, die Sie sehen möchten",
"episode": "Wählen Sie eine Folge aus",
"captions": "Wählen Sie eine Untertitelsprache",
"captionPreferences": "Passen Sie das Erscheinungsbild von Untertiteln an",
"playbackSpeed": "Wiedergabegeschwindigkeit ändern"
}
},
"errors": {
"fatalError": "Der Videoplayer hat einen Fehler festgestellt, bitte melden Sie ihn dem Server <0>Discord</0> Oder weiter <1>GitHub</1>."
}
},
"settings": {
"title": "Einstellungen",
"language": "Sprache",
"captionLanguage": "Untertitelsprache"
},
"v3": {
"newSiteTitle": "Neue Version verfügbar!",
"newDomain": "https://movie-web.app",
"newDomainText": "movie-web zieht in Kürze auf eine neue Domain um: <0>https://movie-web.app</0>. <1>Die alte Website funktioniert nicht mehr {{date}}.</1>",
"tireless": "Wir haben unermüdlich an diesem neuen Update gearbeitet und hoffen, dass Ihnen das gefällt, was wir in den letzten Monaten vorbereitet haben.",
"leaveAnnouncement": "Bring mich dahin!"
},
"casting": {
"casting": "An Gerät übertragen..."
},
"errors": {
"offline": "Ihre Internetverbindung ist instabil"
}
}

View File

@@ -58,7 +58,7 @@
"backToHomeShort": "Back",
"seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} left",
"finishAt": "Finish at {{timeFinished}}",
"finishAt": "Finish at {{timeFinished, datetime}}",
"buttons": {
"episodes": "Episodes",
"source": "Source",

View File

@@ -16,16 +16,34 @@
"placeholder": "Que voulez-vous voir?"
},
"media": {
"title": "Impossible de trouver ce média",
"description": "Nous n'avons pas pu trouver le média que vous avez demandé. Soit il a été supprimé, soit vous avez altéré l'URL."
"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>."
}
},
"provider": {
"title": "Ce fournisseur a été désactivé",
"description": "Nous avons eu des problèmes avec le fournisseur ou bien il était trop instable pour être utilisé, donc nous avons dû le désactiver."
"seasons": {
"seasonAndEpisode": "S{{season}} E{{episode}}"
},
"page": {
"title": "Impossible de trouver cette page",
"description": "Nous avons cherché partout : sous les poubelles, dans le placard, derrière le proxy, mais nous n'avons finalement pas pu trouver la page que vous recherchez."
"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",
@@ -40,7 +58,7 @@
"backToHomeShort": "Retour",
"seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} restant",
"finishAt": "Terminer à {{timeFinished}}",
"finishAt": "Terminer à {{timeFinished, datetime}}",
"buttons": {
"episodes": "Épisodes",
"source": "Source",
@@ -51,9 +69,12 @@
"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",
@@ -77,13 +98,19 @@
"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"
"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",

View File

@@ -58,7 +58,7 @@
"backToHomeShort": "Terug",
"seasonAndEpisode": "S{{season}} A{{episode}}",
"timeLeft": "Nog {{timeLeft}}",
"finishAt": "Afgelopen om {{timeFinished}}",
"finishAt": "Afgelopen om {{timeFinished, datetime}}",
"buttons": {
"episodes": "Afleveringen",
"source": "Bron",

View 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"
}
}

View File

@@ -0,0 +1,127 @@
{
"global": {
"name": "movie-web"
},
"search": {
"loading_series": "正在获取您最喜欢的连续剧……",
"loading_movie": "正在获取您最喜欢的影片……",
"loading": "载入中……",
"allResults": "以上是我们能找到的所有结果!",
"noResults": "我们找不到任何结果!",
"allFailed": "查找媒体失败,请重试!",
"headingTitle": "搜索结果",
"bookmarks": "书签",
"continueWatching": "继续观看",
"title": "您想看些什么?",
"placeholder": "您想看些什么?"
},
"media": {
"movie": "电影",
"series": "连续剧",
"stopEditing": "退出编辑",
"errors": {
"genericTitle": "哎呀,出问题了!",
"failedMeta": "加载元数据失败",
"mediaFailed": "我们未能请求到您要求的媒体,检查互联网连接并重试。",
"videoFailed": "我们在播放您要求的视频时遇到了错误。如果错误持续发生,请向 <0>Discord 服务器</0>或 <1>GitHub</1> 提交问题报告。"
}
},
"seasons": {
"seasonAndEpisode": "第{{season}}季 第{{episode}}集"
},
"notFound": {
"genericTitle": "未找到",
"backArrow": "返回首页",
"media": {
"title": "无法找到媒体",
"description": "我们无法找到您请求的媒体。它可能已被删除,或您篡改了 URL"
},
"provider": {
"title": "该内容提供者已被停用",
"description": "我们的提供者出现问题,或是太不稳定,导致无法使用,所以我们不得不将其停用。"
},
"page": {
"title": "无法找到页面",
"description": "我们已经到处找过了:不管是垃圾桶下、橱柜里或是代理之后。但最终并没有发现您查找的页面。"
}
},
"searchBar": {
"movie": "电影",
"series": "连续剧",
"Search": "搜索"
},
"videoPlayer": {
"findingBestVideo": "正在为您探测最佳视频",
"noVideos": "哎呀,无法为您找到任何视频",
"loading": "载入中……",
"backToHome": "返回首页",
"backToHomeShort": "返回",
"seasonAndEpisode": "第{{season}}季 第{{episode}}集",
"timeLeft": "还剩余 {{timeLeft}}",
"finishAt": "在 {{timeFinished, datetime}} 结束",
"buttons": {
"episodes": "分集",
"source": "视频源",
"captions": "字幕",
"download": "下载",
"settings": "设置",
"pictureInPicture": "画中画",
"playbackSpeed": "播放速度"
},
"popouts": {
"back": "返回",
"sources": "视频源",
"seasons": "分季",
"captions": "字幕",
"playbackSpeed": "播放速度",
"customPlaybackSpeed": "自定义播放速度",
"captionPreferences": {
"title": "自定义",
"delay": "延迟",
"fontSize": "尺寸",
"opacity": "透明度",
"color": "颜色"
},
"episode": "第{{index}}集 - {{title}}",
"noCaptions": "没有字幕",
"linkedCaptions": "已链接字幕",
"customCaption": "自定义字幕",
"uploadCustomCaption": "上传字幕",
"noEmbeds": "未发现该视频源的嵌入内容",
"errors": {
"loadingWentWong": "加载 {{seasonTitle}} 的分集时出现了一些问题",
"embedsError": "为您喜欢的这一东西加载嵌入内容时出现了一些问题"
},
"descriptions": {
"sources": "您想使用哪个内容提供者?",
"embeds": "选择要观看的视频",
"seasons": "选择您要观看的季",
"episode": "选择一个分集",
"captions": "选择字幕语言",
"captionPreferences": "让字幕看起来如您所想",
"playbackSpeed": "改变播放速度"
}
},
"errors": {
"fatalError": "视频播放器遇到致命错误,请向 <0>Discord 服务器</0>或 <1>GitHub</1> 报告。"
}
},
"settings": {
"title": "设置",
"language": "语言",
"captionLanguage": "字幕语言"
},
"v3": {
"newSiteTitle": "新的版本现已发布!",
"newDomain": "https://movie-web.app",
"newDomainText": "movie-web 将很快转移到新的域名:<0>https://movie-web.app</0>。请确保已经更新全部书签链接,<1>旧网站将于 {{date}} 停止工作。</1>",
"tireless": "为了这一新版本,我们不懈努力,希望您会喜欢我们在过去几个月中所做的一切。",
"leaveAnnouncement": "请带我去!"
},
"casting": {
"casting": "正在投射到设备……"
},
"errors": {
"offline": "检查您的互联网连接"
}
}

View File

@@ -1,7 +1,8 @@
import * as Sentry from "@sentry/react";
import { CaptureConsole, HttpClient } from "@sentry/integrations";
import { SENTRY_DSN } from "@/setup/constants";
import * as Sentry from "@sentry/react";
import { conf } from "@/setup/config";
import { SENTRY_DSN } from "@/setup/constants";
Sentry.init({
dsn: SENTRY_DSN,

View File

@@ -1,6 +1,8 @@
import { ReactNode, createContext, useContext, useMemo } from "react";
import { MWMediaMeta } from "@/backend/metadata/types";
import { useStore } from "@/utils/storage";
import { createContext, ReactNode, useContext, useMemo } from "react";
import { BookmarkStore } from "./store";
import { BookmarkStoreData } from "./types";

View File

@@ -1,6 +1,7 @@
import { createVersionedStore } from "@/utils/storage";
import { migrateV1Bookmarks, OldBookmarks } from "../watched/migrations/v2";
import { BookmarkStoreData } from "./types";
import { OldBookmarks, migrateV1Bookmarks } from "../watched/migrations/v2";
export const BookmarkStore = createVersionedStore<BookmarkStoreData>()
.setKey("mw-bookmarks")

View File

@@ -1,6 +1,8 @@
import { useStore } from "@/utils/storage";
import { createContext, ReactNode, useContext, useMemo } from "react";
import { ReactNode, createContext, useContext, useMemo } from "react";
import { LangCode } from "@/setup/iso6391";
import { useStore } from "@/utils/storage";
import { SettingsStore } from "./store";
import { MWSettingsData } from "./types";

View File

@@ -1,4 +1,5 @@
import { createVersionedStore } from "@/utils/storage";
import { MWSettingsData, MWSettingsDataV1 } from "./types";
export const SettingsStore = createVersionedStore<MWSettingsData>()

View File

@@ -1,16 +1,18 @@
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { MWMediaType } from "@/backend/metadata/types";
import { useStore } from "@/utils/storage";
import {
createContext,
ReactNode,
createContext,
useCallback,
useContext,
useMemo,
useRef,
} from "react";
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { MWMediaType } from "@/backend/metadata/types";
import { useStore } from "@/utils/storage";
import { VideoProgressStore } from "./store";
import { StoreMediaItem, WatchedStoreItem, WatchedStoreData } from "./types";
import { StoreMediaItem, WatchedStoreData, WatchedStoreItem } from "./types";
const FIVETEEN_MINUTES = 15 * 60;
const FIVE_MINUTES = 5 * 60;

View File

@@ -2,6 +2,7 @@ import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
import { searchForMedia } from "@/backend/metadata/search";
import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types";
import { compareTitle } from "@/utils/titleMatch";
import { WatchedStoreData, WatchedStoreItem } from "../types";
interface OldMediaBase {

View File

@@ -1,5 +1,6 @@
import { createVersionedStore } from "@/utils/storage";
import { migrateV2Videos, OldData } from "./migrations/v2";
import { OldData, migrateV2Videos } from "./migrations/v2";
import { WatchedStoreData } from "./types";
export const VideoProgressStore = createVersionedStore<WatchedStoreData>()

View File

@@ -1,36 +1,38 @@
import { ReactNode, useCallback, useState } from "react";
import { Transition } from "@/components/Transition";
import { useIsMobile } from "@/hooks/useIsMobile";
import { AirplayAction } from "@/video/components/actions/AirplayAction";
import { BackdropAction } from "@/video/components/actions/BackdropAction";
import { CastingTextAction } from "@/video/components/actions/CastingTextAction";
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
import { FullscreenAction } from "@/video/components/actions/FullscreenAction";
import { HeaderAction } from "@/video/components/actions/HeaderAction";
import { KeyboardShortcutsAction } from "@/video/components/actions/KeyboardShortcutsAction";
import { LoadingAction } from "@/video/components/actions/LoadingAction";
import { MiddlePauseAction } from "@/video/components/actions/MiddlePauseAction";
import { MobileCenterAction } from "@/video/components/actions/MobileCenterAction";
import { PageTitleAction } from "@/video/components/actions/PageTitleAction";
import { PauseAction } from "@/video/components/actions/PauseAction";
import { PictureInPictureAction } from "@/video/components/actions/PictureInPictureAction";
import { ProgressAction } from "@/video/components/actions/ProgressAction";
import { SeriesSelectionAction } from "@/video/components/actions/SeriesSelectionAction";
import { ShowTitleAction } from "@/video/components/actions/ShowTitleAction";
import { KeyboardShortcutsAction } from "@/video/components/actions/KeyboardShortcutsAction";
import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction";
import { TimeAction } from "@/video/components/actions/TimeAction";
import { VolumeAction } from "@/video/components/actions/VolumeAction";
import { VideoPlayerError } from "@/video/components/parts/VideoPlayerError";
import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderAction";
import {
VideoPlayerBase,
VideoPlayerBaseProps,
} from "@/video/components/VideoPlayerBase";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { ReactNode, useCallback, useState } from "react";
import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderAction";
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
import { CastingTextAction } from "@/video/components/actions/CastingTextAction";
import { PictureInPictureAction } from "@/video/components/actions/PictureInPictureAction";
import { CaptionRendererAction } from "./actions/CaptionRendererAction";
import { SettingsAction } from "./actions/SettingsAction";
import { DividerAction } from "./actions/DividerAction";
import { SettingsAction } from "./actions/SettingsAction";
import { VolumeAdjustedAction } from "./actions/VolumeAdjustedAction";
type Props = VideoPlayerBaseProps;
@@ -118,7 +120,7 @@ export function VideoPlayer(props: Props) {
<Transition
animation="slide-down"
show={show}
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col px-8 py-6 pb-2"
>
<HeaderAction
showControls={isMobile}

View File

@@ -1,15 +1,17 @@
import { useRef } from "react";
import { CastingInternal } from "@/video/components/internal/CastingInternal";
import { WrapperRegisterInternal } from "@/video/components/internal/WrapperRegisterInternal";
import { VideoErrorBoundary } from "@/video/components/parts/VideoErrorBoundary";
import { useInterface } from "@/video/state/logic/interface";
import { useMeta } from "@/video/state/logic/meta";
import { useRef } from "react";
import {
useVideoPlayerDescriptor,
VideoPlayerContextProvider,
} from "../state/hooks";
import { MetaAction } from "./actions/MetaAction";
import { VideoElementInternal } from "./internal/VideoElementInternal";
import {
VideoPlayerContextProvider,
useVideoPlayerDescriptor,
} from "../state/hooks";
export interface VideoPlayerBaseProps {
children?:

View File

@@ -1,8 +1,10 @@
import { useCallback } from "react";
import { Icons } from "@/components/Icon";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useMisc } from "@/video/state/logic/misc";
import { useCallback } from "react";
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
interface Props {

View File

@@ -1,8 +1,9 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useInterface } from "@/video/state/logic/interface";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import React, { useCallback, useEffect, useRef, useState } from "react";
interface BackdropActionProps {
children?: React.ReactNode;

View File

@@ -1,9 +1,11 @@
import { useCallback, useEffect, useRef } from "react";
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 { sanitize, parseSubtitles } from "@/backend/helpers/captions";
import { ContentCaption } from "subsrt-ts/dist/types/handler";
import { useRef } from "react";
import { useAsync } from "react-use";
import { useVideoPlayerDescriptor } from "../../state/hooks";
import { useProgress } from "../../state/logic/progress";
import { useSource } from "../../state/logic/source";
@@ -48,9 +50,14 @@ export function CaptionRendererAction({
const descriptor = useVideoPlayerDescriptor();
const source = useSource(descriptor).source;
const videoTime = useProgress(descriptor).time;
const { captionSettings } = useSettings();
const { captionSettings, setCaptionDelay } = useSettings();
const captions = useRef<ContentCaption[]>([]);
const captionSetRef = useRef<(delay: number) => void>(setCaptionDelay);
useEffect(() => {
captionSetRef.current = setCaptionDelay;
}, [setCaptionDelay]);
useAsync(async () => {
const blobUrl = source?.caption?.url;
if (blobUrl) {
@@ -61,20 +68,38 @@ export function CaptionRendererAction({
} catch (error) {
captions.current = [];
}
// reset delay on every subtitle change
setCaptionDelay(0);
} else {
captions.current = [];
}
}, [source?.caption?.url]);
// reset delay when loading new source url
useEffect(() => {
captionSetRef.current(0);
}, [source?.caption?.url]);
const isVisible = useCallback(
(
start: number,
end: number,
delay: number,
currentTime: number
): boolean => {
const delayedStart = start / 1000 + delay;
const delayedEnd = end / 1000 + delay;
return (
Math.max(0, delayedStart) <= currentTime &&
Math.max(0, delayedEnd) >= currentTime
);
},
[]
);
if (!captions.current.length) return null;
const isVisible = (start: number, end: number): boolean => {
const delayedStart = start / 1000 + captionSettings.delay;
const delayedEnd = end / 1000 + captionSettings.delay;
return (
Math.max(0, delayedStart) <= videoTime &&
Math.max(0, delayedEnd) >= videoTime
);
};
const visibileCaptions = captions.current.filter(({ start, end }) =>
isVisible(start, end, captionSettings.delay, videoTime)
);
return (
<Transition
className={[
@@ -84,12 +109,9 @@ export function CaptionRendererAction({
animation="slide-up"
show
>
{captions.current.map(
({ start, end, content }) =>
isVisible(start, end) && (
<CaptionCue key={`${start}-${end}`} text={content} />
)
)}
{visibileCaptions.map(({ start, end, content }) => (
<CaptionCue key={`${start}-${end}`} text={content} />
))}
</Transition>
);
}

View File

@@ -1,7 +1,8 @@
import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMisc } from "@/video/state/logic/misc";
import { useTranslation } from "react-i18next";
export function CastingTextAction() {
const { t } = useTranslation();

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Icons } from "@/components/Icon";
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMisc } from "@/video/state/logic/misc";
import { useCallback, useEffect, useRef, useState } from "react";
interface Props {
className?: string;

View File

@@ -1,6 +1,6 @@
import { MWMediaType } from "@/backend/metadata/types";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMeta } from "@/video/state/logic/meta";
import { MWMediaType } from "@/backend/metadata/types";
export function DividerAction() {
const descriptor = useVideoPlayerDescriptor();

View File

@@ -1,9 +1,11 @@
import { useCallback } from "react";
import { Icons } from "@/components/Icon";
import { canFullscreen } from "@/utils/detectFeatures";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useInterface } from "@/video/state/logic/interface";
import { useCallback } from "react";
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
interface Props {

View File

@@ -1,11 +1,12 @@
import { useEffect, useRef } from "react";
import { useVolumeControl } from "@/hooks/useVolumeToggle";
import { getPlayerState } from "@/video/state/cache";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useInterface } from "@/video/state/logic/interface";
import { getPlayerState } from "@/video/state/cache";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useProgress } from "@/video/state/logic/progress";
import { useVolumeControl } from "@/hooks/useVolumeToggle";
export function KeyboardShortcutsAction() {
const descriptor = useVideoPlayerDescriptor();
@@ -60,7 +61,7 @@ export function KeyboardShortcutsAction() {
// Mute
case "m":
toggleVolume();
toggleVolume(true);
break;
// Decrease volume

View File

@@ -1,9 +1,10 @@
import { useEffect } from "react";
import { MWCaption } from "@/backend/helpers/streams";
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMeta } from "@/video/state/logic/meta";
import { useProgress } from "@/video/state/logic/progress";
import { useEffect } from "react";
export type WindowMeta = {
meta: DetailedMeta;

View File

@@ -1,8 +1,9 @@
import { useCallback } from "react";
import { Icon, Icons } from "@/components/Icon";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useCallback } from "react";
export function MiddlePauseAction() {
const descriptor = useVideoPlayerDescriptor();

View File

@@ -1,5 +1,7 @@
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { Helmet } from "react-helmet";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useCurrentSeriesEpisodeInfo } from "../hooks/useCurrentSeriesEpisodeInfo";
export function PageTitleAction() {

View File

@@ -1,8 +1,10 @@
import { useCallback } from "react";
import { Icons } from "@/components/Icon";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useCallback } from "react";
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
interface Props {

View File

@@ -1,13 +1,15 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Icons } from "@/components/Icon";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useTranslation } from "react-i18next";
import { useControls } from "@/video/state/logic/controls";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useCallback } from "react";
import {
canPictureInPicture,
canWebkitPictureInPicture,
} from "@/utils/detectFeatures";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
interface Props {

View File

@@ -1,3 +1,5 @@
import { useCallback, useEffect, useRef } from "react";
import {
makePercentage,
makePercentageString,
@@ -7,7 +9,6 @@ import { getPlayerState } from "@/video/state/cache";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useProgress } from "@/video/state/logic/progress";
import { useCallback, useEffect, useRef } from "react";
export function ProgressAction() {
const descriptor = useVideoPlayerDescriptor();

View File

@@ -1,12 +1,13 @@
import { Icons } from "@/components/Icon";
import { useTranslation } from "react-i18next";
import { MWMediaType } from "@/backend/metadata/types";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMeta } from "@/video/state/logic/meta";
import { Icons } from "@/components/Icon";
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useInterface } from "@/video/state/logic/interface";
import { useTranslation } from "react-i18next";
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
import { useMeta } from "@/video/state/logic/meta";
interface Props {
className?: string;

View File

@@ -1,11 +1,12 @@
import { useTranslation } from "react-i18next";
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 { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
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 {
className?: string;

View File

@@ -1,4 +1,5 @@
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useCurrentSeriesEpisodeInfo } from "../hooks/useCurrentSeriesEpisodeInfo";
export function ShowTitleAction() {

View File

@@ -2,6 +2,7 @@ import { Icons } from "@/components/Icon";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useProgress } from "@/video/state/logic/progress";
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
interface Props {

View File

@@ -1,11 +1,12 @@
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useTranslation } from "react-i18next";
import { useIsMobile } from "@/hooks/useIsMobile";
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 { useProgress } from "@/video/state/logic/progress";
import { useInterface } from "@/video/state/logic/interface";
import { VideoPlayerTimeFormat } from "@/video/state/types";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useControls } from "@/video/state/logic/controls";
function durationExceedsHour(secs: number): boolean {
return secs > 60 * 60;
@@ -54,20 +55,20 @@ export function TimeAction(props: Props) {
hasHours
);
const duration = formatSeconds(videoTime.duration, hasHours);
const timeLeft = formatSeconds(
const remaining = 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,
});
((videoTime.duration - videoTime.time) * 1000) /
mediaPlaying.playbackSpeed
);
const formattedTimeFinished = ` - ${t("videoPlayer.finishAt", {
timeFinished,
formatParams: {
timeFinished: { hour: "numeric", minute: "numeric" },
},
})}`;
let formattedTime: string;
@@ -76,10 +77,10 @@ export function TimeAction(props: Props) {
formattedTime = `${currentTime} ${props.noDuration ? "" : `/ ${duration}`}`;
} else if (timeFormat === VideoPlayerTimeFormat.REMAINING && !isMobile) {
formattedTime = `${t("videoPlayer.timeLeft", {
timeLeft,
timeLeft: remaining,
})}${videoTime.time === videoTime.duration ? "" : formattedTimeFinished} `;
} else if (timeFormat === VideoPlayerTimeFormat.REMAINING && isMobile) {
formattedTime = `-${timeLeft}`;
formattedTime = `-${remaining}`;
} else {
formattedTime = "";
}

View File

@@ -1,3 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Icon, Icons } from "@/components/Icon";
import {
makePercentage,
@@ -10,7 +12,6 @@ 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 { useCallback, useEffect, useRef, useState } from "react";
interface Props {
className?: string;

View File

@@ -14,7 +14,7 @@ export function VolumeAdjustedAction() {
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",
"absolute left-1/2 z-[100] flex -translate-x-1/2 items-center space-x-4 rounded-full bg-bink-300 bg-opacity-50 px-5 py-2 transition-all duration-100",
].join(" ")}
>
<Icon

View File

@@ -1,5 +1,7 @@
import { Icons } from "@/components/Icon";
import { useTranslation } from "react-i18next";
import { Icons } from "@/components/Icon";
import { PopoutListAction } from "../../popouts/PopoutUtils";
interface Props {

View File

@@ -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 { 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 { useSource } from "@/video/state/logic/source";
import { PopoutListAction } from "../../popouts/PopoutUtils";
export function DownloadAction() {

View File

@@ -1,5 +1,7 @@
import { Icons } from "@/components/Icon";
import { useTranslation } from "react-i18next";
import { Icons } from "@/components/Icon";
import { PopoutListAction } from "../../popouts/PopoutUtils";
interface Props {

View File

@@ -8,7 +8,7 @@ export function QualityDisplayAction() {
if (!source.source) return null;
return (
<div className="rounded-md bg-denim-300 py-1 px-2 transition-colors">
<div className="rounded-md bg-denim-300 px-2 py-1 transition-colors">
<p className="text-center text-xs font-bold text-slate-300 transition-colors">
{source.source.quality}
</p>

View File

@@ -1,7 +1,9 @@
import { Icons } from "@/components/Icon";
import { useTranslation } from "react-i18next";
import { PopoutListAction } from "../../popouts/PopoutUtils";
import { Icons } from "@/components/Icon";
import { QualityDisplayAction } from "./QualityDisplayAction";
import { PopoutListAction } from "../../popouts/PopoutUtils";
interface Props {
onClick?: () => any;

View File

@@ -1,9 +1,10 @@
import { useEffect } from "react";
import { MWCaption } from "@/backend/helpers/streams";
import { MWSeasonWithEpisodeMeta } from "@/backend/metadata/types";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { VideoPlayerMeta } from "@/video/state/types";
import { useEffect } from "react";
interface MetaControllerProps {
data?: VideoPlayerMeta;

Some files were not shown because too many files have changed in this diff Show More