Merge branch 'dev' of https://github.com/movie-web/movie-web into thumbnails

This commit is contained in:
frost768 2023-06-07 21:53:45 +03:00
commit 49106c1254
31 changed files with 1922 additions and 656 deletions

View File

@ -1,6 +1,6 @@
{
"name": "movie-web",
"version": "3.0.14",
"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": {

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,20 +1,33 @@
import DOMPurify from "dompurify";
import { detect, list, parse } 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 } from "@/backend/helpers/streams";
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,
});
@ -23,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) {
@ -33,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,4 +1,4 @@
import { ofetch } from "ofetch";
import { FetchOptions, FetchResponse, ofetch } from "ofetch";
import { conf } from "@/setup/config";
@ -59,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

@ -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,8 @@ import "./providers/superstream";
import "./providers/netfilm";
import "./providers/m4ufree";
import "./providers/hdwatched";
import "./providers/2embed";
import "./providers/sflix";
// embeds
import "./embeds/streamm4u";

View File

@ -29,8 +29,8 @@ interface JWDetailedMeta extends JWMediaResult {
export interface DetailedMeta {
meta: MWMediaMeta;
tmdbId: string;
imdbId: string;
imdbId?: string;
tmdbId?: string;
}
export async function getMetaFromId(
@ -67,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

@ -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,15 +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 {
@ -20,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,
};
}
@ -55,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,
@ -75,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),
@ -99,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,
@ -117,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

@ -41,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

@ -0,0 +1,99 @@
import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
const sflixBase = "https://sflix.video";
registerProvider({
id: "sflix",
displayName: "Sflix",
rank: 50,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode, progress }) {
let searchQuery = `${media.meta.title} `;
if (media.meta.type === MWMediaType.MOVIE)
searchQuery += media.meta.year ?? "";
if (media.meta.type === MWMediaType.SERIES)
searchQuery += `S${String(media.meta.seasonData.number).padStart(
2,
"0"
)}`;
const search = await proxiedFetch<any>(
`/?s=${encodeURIComponent(searchQuery)}`,
{
baseURL: sflixBase,
}
);
const searchPage = new DOMParser().parseFromString(search, "text/html");
const moviePageUrl = searchPage
.querySelector(".movies-list .ml-item:first-child a")
?.getAttribute("href");
if (!moviePageUrl) throw new Error("Movie does not exist");
progress(25);
const movie = await proxiedFetch<any>(moviePageUrl);
const moviePage = new DOMParser().parseFromString(movie, "text/html");
progress(45);
let outerEmbedSrc = null;
if (media.meta.type === MWMediaType.MOVIE) {
outerEmbedSrc = moviePage
.querySelector("iframe")
?.getAttribute("data-lazy-src");
} else if (media.meta.type === MWMediaType.SERIES) {
const series = Array.from(moviePage.querySelectorAll(".desc p a")).map(
(a) => ({
title: a.getAttribute("title"),
link: a.getAttribute("href"),
})
);
const episodeNumber = media.meta.seasonData.episodes.find(
(e) => e.id === episode
)?.number;
const targetSeries = series.find((s) =>
s.title?.endsWith(String(episodeNumber).padStart(2, "0"))
);
if (!targetSeries) throw new Error("Episode does not exist");
outerEmbedSrc = targetSeries.link;
}
if (!outerEmbedSrc) throw new Error("Outer embed source not found");
progress(65);
const outerEmbed = await proxiedFetch<any>(outerEmbedSrc);
const outerEmbedPage = new DOMParser().parseFromString(
outerEmbed,
"text/html"
);
const embedSrc = outerEmbedPage
.querySelector("iframe")
?.getAttribute("src");
if (!embedSrc) throw new Error("Embed source not found");
const embed = await proxiedFetch<string>(embedSrc);
const streamUrl = embed.match(/file\s*:\s*"([^"]+\.mp4)"/)?.[1];
if (!streamUrl) throw new Error("Unable to get stream");
return {
embeds: [],
stream: {
streamUrl,
quality: MWStreamQuality.Q1080P,
type: MWStreamType.MP4,
captions: [],
},
};
},
});

View File

@ -1,6 +1,10 @@
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 {
@ -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

@ -0,0 +1,17 @@
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
export function useQueryParams() {
const loc = useLocation();
const queryParams = useMemo(() => {
// Basic absolutely-not-fool-proof URL query param parser
const obj: Record<string, string> = Object.fromEntries(
new URLSearchParams(loc.search).entries()
);
return obj;
}, [loc]);
return queryParams;
}

View File

@ -1,13 +1,16 @@
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
// Languages
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 pirate from "./locales/pirate/translation.json";
import tr from "./locales/tr/translation.json";
import zh from "./locales/zh/translation.json";
const locales = {
en: {
@ -22,11 +25,20 @@ const locales = {
fr: {
translation: fr,
},
de: {
translation: de,
},
zh: {
translation: zh,
},
cs: {
translation: cs,
},
pirate: {
translation: pirate,
},
};
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

@ -178,4 +178,20 @@ input[type=range].styled-slider.slider-progress::-ms-fill-lower {
background: var(--slider-progress-background);
border: none;
border-right-width: 0;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
background-color: theme("colors.denim-500");
border: 5px solid transparent;
border-left: 0;
background-clip: content-box;
}
::-webkit-scrollbar {
/* For some reason the styles don't get applied without the width */
width: 13px;
}

View File

@ -1,5 +1,6 @@
export type LangCode =
| "none"
| "pirate"
| "aa"
| "ab"
| "ae"
@ -219,6 +220,12 @@ export const captionLanguages: CaptionLanguageOption[] = [
name: "None",
nativeName: "Lorem ipsum",
},
{
id: "pirate",
englishName: "Pirate",
name: "Pirate English",
nativeName: "Pirate English",
},
{
id: "aa",
englishName: "Afar",

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 deiner Lieblingsserie...",
"loading_movie": "Auf der Suche nach deinen 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 gucken?",
"placeholder": "Was willst du gucken?"
},
"media": {
"movie": "Filme",
"series": "Serie",
"stopEditing": "Beenden die Bearbeitung",
"errors": {
"genericTitle": "Hoppla, etwas ist schiefgegangen!",
"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": "Suchen"
},
"videoPlayer": {
"findingBestVideo": "Auf der Suche nach dem besten Video für Sie",
"noVideos": "Entschuldigung, wir konnten keine Videos finden",
"loading": "Wird geladen...",
"backToHome": "Zurück zur Startseite",
"backToHomeShort": "Rückmeldung",
"seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} verbleibend",
"finishAt": "Endet 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": "Staffel",
"captions": "Untertitel",
"playbackSpeed": "Lesegeschwindigkeit",
"customPlaybackSpeed": "Benutzerdefinierte Wiedergabegeschwindigkeit",
"captionPreferences": {
"title": "Bearbeiten",
"delay": "Verzögerung",
"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öchtest du nutzen?",
"embeds": "Wähle das Video aus, das du ansehen möchten",
"seasons": "Wähle die Staffel aus, die du sehen möchten",
"episode": "Wähle eine Folge aus",
"captions": "Wähle eine Untertitelsprache",
"captionPreferences": "Passe das Erscheinungsbild von Untertiteln an",
"playbackSpeed": "Wiedergabegeschwindigkeit ändern"
}
},
"errors": {
"fatalError": "Der Videoplayer hat einen Fehler festgestellt, bitte melde 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 dir gefällt, was wir in den letzten Monaten vorbereitet haben.",
"leaveAnnouncement": "Bring mich dahin!"
},
"casting": {
"casting": "An Gerät übertragen..."
},
"errors": {
"offline": "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

@ -58,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",

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,124 @@
{
"global": {
"name": "movie-web"
},
"search": {
"loading_series": "Fetchin' yer favorite series...",
"loading_movie": "Fetchin' yer favorite movies...",
"loadin'": "Loadin'...",
"allResults": "That be all we 'ave, me hearty!",
"noResults": "We couldn't find anythin' that matches yer search!",
"allFailed": "Failed t' find media, walk the plank and try again!",
"headingTitle": "Search results",
"bookmarks": "Treasure Maps",
"continueWatchin'": "Continue Watchin'",
"title": "Wha' be ye wantin' to watch, me matey?",
"placeholder": "Wha' be ye searchin' for?"
},
"media": {
"movie": "Movie",
"series": "Series",
"stopEditin'": "Stop editin'",
"errors": {
"genericTitle": "Shiver me timbers! It broke!",
"failedMeta": "Ye can't trust the compass, failed to load meta",
"mediaFailed": "We failed t' request the media ye asked fer, check yer internet connection, or Davy Jones's locker awaits ye!",
"videoFailed": "Blimey! We encountered an error while playin' the video ye requested. If this keeps happening please report the issue to the <0>Discord server</0> or on <1>GitHub</1>."
}
},
"seasons": {
"seasonAndEpisode": "S{{season}} E{{episode}}"
},
"notFound": {
"genericTitle": "Ahoy! I see nothin' on the horizon.",
"backArrow": "Back to the port",
"media": {
"title": "Avast ye! Couldn't find that media",
"description": "We couldn't find the media ye requested. Either it's been scuttled or ye tampered with the URL, ye scallywag!"
},
"provider": {
"title": "Walk the plank! This provider has been disabled",
"description": "We had issues wit' the provider or 'twas too unstable t' use, so we had t' disable it. Try another one, arrr!"
},
"page": {
"title": "Avast ye! Couldn't find that page.",
"description": "Arrr! We searched every inch o' the vessel: from the bilge to the crow's nest, from the keel to the topmast, but avast! We couldn't find the page ye be lookin' fer, me heartie."
}
},
"searchBar": {
"movie": "Movie",
"series": "Series",
"Search": "Search"
},
"videoPlayer": {
"findingBestVideo": "Finding the best video fer ye, hoist the colors!",
"noVideos": "Blistering barnacles, couldn't find any videos fer ye. Ye need a better map!",
"loading": "Loading...",
"backToHome": "Back to the port, mates!",
"backToHomeShort": "Back",
"seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} left",
"finishAt": "Finish at {{timeFinished}}",
"buttons": {
"episodes": "Episodes",
"source": "Source",
"captions": "Captions",
"download": "Download",
"settings": "Settings",
"pictureInPicture": "Spyglass view",
"playbackSpeed": "Set sail!"
},
"popouts": {
"back": "Avast ye, go back!",
"sources": "Wha' provider do ye want to use?",
"seasons": "Choose which season you wants to watch!",
"captions": "Select a subtitle language, me hearty!",
"playbackSpeed": "Change the speed of Blackbeard's ship!",
"customPlaybackSpeed": "Set a custom playback speed",
"captionPreferences": {
"title": "Customize yer captions",
"delay": "Delay",
"fontSize": "Size",
"opacity": "Opacity",
"color": "Color"
},
"episode": "E{{index}} - {{title}}",
"noCaptions": "No captions, hoist the Jolly Roger!",
"linkedCaptions": "Linked captions, drop anchor!",
"customCaption": "Custom caption, arrr!",
"uploadCustomCaption": "Upload yer own caption!",
"noEmbeds": "No embeds we be found fer this source",
"errors": {
"loadingWentWong": "Shiver me timbers! Somethin' went wrong loadin' the episodes fer {{seasonTitle}}",
"embedsError": "Blimey! Somethin' went wrong loadin' the embeds fer this thin' that ye like"
},
"descriptions": {
"sources": "Wha' provider do ye wants to use?",
"embeds": "Choose which video to view",
"seasons": "Choose which season ye wants to watch",
"episode": "Pick an episode",
"captions": "Choose a subtitle language",
"captionPreferences": "Make subtitles look how ye wants it",
"playbackSpeed": "Change the playback speed"
}
},
"errors": {
"fatalError": "Blow me down! The video player encounted a fatal error, please report it to the <0>Discord server</0> or on <1>GitHub</1>."
}
},
"settings": {
"title": "Settings",
"language": "Language",
"captionLanguage": "Caption Language"
},
"v3": {
"newSiteTitle": "New version now released!",
"newDomain": "https://movie-web.app",
"newDomainText": "movie-web will soon be movin' to a new domain: <0>https://movie-web.app</0>. Make sure to update all yer bookmarks as <1>the ole website will stop workin' on {{date}}.</1>",
"tireless": "We've worked tirelessly on this new update, we hope ye will enjoy wha' we've been cookin' up fer the past months.",
"leaveAnnouncement": "Take me thar!"
},
"casting": { "casting": "Casting to device..." },
"errors": { "offline": "Avast! Check yer internet connection" }
}

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,4 +1,4 @@
import { useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useAsync } from "react-use";
import { ContentCaption } from "subsrt-ts/dist/types/handler";
@ -50,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) {
@ -63,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={[
@ -86,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

@ -55,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;
@ -77,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,6 +1,7 @@
import throttle from "lodash.throttle";
import { useEffect, useMemo, useRef } from "react";
import { useQueryParams } from "@/hooks/useQueryParams";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
@ -20,6 +21,7 @@ export function ProgressListenerController(props: Props) {
const misc = useMisc(descriptor);
const didInitialize = useRef<true | null>(null);
const lastTime = useRef<number>(props.startAt ?? 0);
const queryParams = useQueryParams();
// time updates (throttled)
const updateTime = useMemo(
@ -56,9 +58,26 @@ export function ProgressListenerController(props: Props) {
useEffect(() => {
if (lastStateProviderId.current === stateProviderId) return;
if (mediaPlaying.isFirstLoading) return;
lastStateProviderId.current = stateProviderId;
if ((queryParams.t ?? null) !== null) {
// Convert `t` param to time. Supports having only seconds (like `?t=192`), but also `3:30` or `1:30:02`
const timeArr = queryParams.t.toString().split(":").map(Number).reverse(); // This is an array of [seconds, ?minutes, ?hours] as ints.
const hours = timeArr[2] ?? 0;
const minutes = Math.min(timeArr[1] ?? 0, 59);
const seconds = Math.min(timeArr[0] ?? 0, minutes > 0 ? 59 : Infinity);
const timeInSeconds = hours * 60 * 60 + minutes * 60 + seconds;
controls.setTime(timeInSeconds);
return;
}
controls.setTime(lastTime.current);
}, [controls, mediaPlaying, stateProviderId]);
}, [controls, mediaPlaying, stateProviderId, queryParams]);
useEffect(() => {
// if it initialized, but media starts loading for the first time again.

View File

@ -9,8 +9,6 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useInterface } from "@/video/state/logic/interface";
import "./Popouts.css";
function ShowPopout(props: { popoutId: string | null; onClose: () => void }) {
const popoutMap = {
settings: <SettingsPopout />,

View File

@ -1,15 +0,0 @@
.popout-wrapper ::-webkit-scrollbar-track {
background-color: transparent;
}
.popout-wrapper ::-webkit-scrollbar-thumb {
background-color: theme("colors.denim-500");
border: 5px solid transparent;
border-left: 0;
background-clip: content-box;
}
.popout-wrapper ::-webkit-scrollbar {
/* For some reason the styles don't get applied without the width */
width: 13px;
}

1136
yarn.lock

File diff suppressed because it is too large Load Diff