mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-24 19:41:49 +01:00
linting
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
parent
c90d59ef93
commit
b3db58012f
@ -50,6 +50,7 @@ module.exports = {
|
||||
"no-await-in-loop": "off",
|
||||
"no-nested-ternary": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
"react/jsx-filename-extension": [
|
||||
"error",
|
||||
{ extensions: [".js", ".tsx", ".jsx"] }
|
||||
|
@ -1,20 +1,19 @@
|
||||
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||
|
||||
registerEmbedScraper({
|
||||
id: "playm4u",
|
||||
displayName: "playm4u",
|
||||
for: MWEmbedType.PLAYM4U,
|
||||
rank: 0,
|
||||
async getStream(ctx) {
|
||||
// throw new Error("Oh well 2")
|
||||
return {
|
||||
streamUrl: '',
|
||||
quality: MWStreamQuality.Q1080P,
|
||||
captions: [],
|
||||
type: MWStreamType.MP4,
|
||||
};
|
||||
},
|
||||
})
|
||||
id: "playm4u",
|
||||
displayName: "playm4u",
|
||||
for: MWEmbedType.PLAYM4U,
|
||||
rank: 0,
|
||||
async getStream() {
|
||||
// throw new Error("Oh well 2")
|
||||
return {
|
||||
streamUrl: "",
|
||||
quality: MWStreamQuality.Q1080P,
|
||||
captions: [],
|
||||
type: MWStreamType.MP4,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -1,60 +1,65 @@
|
||||
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||
import { MWStreamQuality, MWStreamType, MWStream } from "@/backend/helpers/streams";
|
||||
import {
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
MWStream,
|
||||
} from "@/backend/helpers/streams";
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
|
||||
const HOST = 'streamm4u.club';
|
||||
const HOST = "streamm4u.club";
|
||||
const URL_BASE = `https://${HOST}`;
|
||||
const URL_API = `${URL_BASE}/api`;
|
||||
const URL_API_SOURCE = `${URL_API}/source`;
|
||||
|
||||
async function scrape(embed: string) {
|
||||
const sources: MWStream[] = [];
|
||||
|
||||
const embedID = embed.split("/").pop();
|
||||
|
||||
console.log(`${URL_API_SOURCE}/${embedID}`);
|
||||
const json = await proxiedFetch<any>(`${URL_API_SOURCE}/${embedID}`, {
|
||||
method: "POST",
|
||||
body: `r=&d=${HOST}`,
|
||||
});
|
||||
|
||||
if (json.success) {
|
||||
const streams = json.data;
|
||||
|
||||
for (const stream of streams) {
|
||||
sources.push({
|
||||
streamUrl: stream.file as string,
|
||||
quality: stream.label as MWStreamQuality,
|
||||
type: stream.type as MWStreamType,
|
||||
captions: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
// TODO check out 403 / 404 on successfully returned video stream URLs
|
||||
registerEmbedScraper({
|
||||
id: "streamm4u",
|
||||
displayName: "streamm4u",
|
||||
for: MWEmbedType.STREAMM4U,
|
||||
rank: 100,
|
||||
async getStream({ progress, url }) {
|
||||
id: "streamm4u",
|
||||
displayName: "streamm4u",
|
||||
for: MWEmbedType.STREAMM4U,
|
||||
rank: 100,
|
||||
async getStream({ progress, url }) {
|
||||
// const scrapingThreads = [];
|
||||
// const streams = [];
|
||||
|
||||
const scrapingThreads = [];
|
||||
let streams = [];
|
||||
const sources = (await scrape(url)).sort(
|
||||
(a, b) =>
|
||||
Number(b.quality.replace("p", "")) - Number(a.quality.replace("p", ""))
|
||||
);
|
||||
// const preferredSourceIndex = 0;
|
||||
const preferredSource = sources[0];
|
||||
|
||||
const sources = (await scrape(url)).sort((a, b) => Number(b.quality.replace("p", "")) - Number(a.quality.replace("p", "")));
|
||||
let preferredSourceIndex = 0;
|
||||
let preferredSource = sources[0];
|
||||
if (!preferredSource) throw new Error("No source found");
|
||||
|
||||
if (!preferredSource) throw new Error("No source found")
|
||||
progress(100);
|
||||
|
||||
progress(100)
|
||||
|
||||
return preferredSource
|
||||
},
|
||||
})
|
||||
|
||||
async function scrape(embed: string) {
|
||||
const sources: MWStream[] = [];
|
||||
|
||||
const embedID = embed.split('/').pop();
|
||||
|
||||
console.log(`${URL_API_SOURCE}/${embedID}`)
|
||||
const json = await proxiedFetch<any>(`${URL_API_SOURCE}/${embedID}`, {
|
||||
method: 'POST',
|
||||
body: `r=&d=${HOST}`
|
||||
});
|
||||
|
||||
if (json.success) {
|
||||
const streams = json.data;
|
||||
|
||||
for (const stream of streams) {
|
||||
sources.push({
|
||||
streamUrl: stream.file as string,
|
||||
quality: stream.label as MWStreamQuality,
|
||||
type: stream.type as MWStreamType,
|
||||
captions: []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
return preferredSource;
|
||||
},
|
||||
});
|
||||
|
@ -3,7 +3,7 @@ import { MWStream } from "./streams";
|
||||
export enum MWEmbedType {
|
||||
M4UFREE = "m4ufree",
|
||||
STREAMM4U = "streamm4u",
|
||||
PLAYM4U = "playm4u"
|
||||
PLAYM4U = "playm4u",
|
||||
}
|
||||
|
||||
export type MWEmbed = {
|
||||
|
@ -25,15 +25,15 @@ type MWProviderRunContextBase = {
|
||||
};
|
||||
type MWProviderRunContextTypeSpecific =
|
||||
| {
|
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||
episode: undefined;
|
||||
season: undefined;
|
||||
}
|
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||
episode: undefined;
|
||||
season: undefined;
|
||||
}
|
||||
| {
|
||||
type: MWMediaType.SERIES;
|
||||
episode: string;
|
||||
season: string;
|
||||
};
|
||||
type: MWMediaType.SERIES;
|
||||
episode: string;
|
||||
season: string;
|
||||
};
|
||||
|
||||
export type MWProviderRunContext = MWProviderRunContextBase &
|
||||
MWProviderRunContextTypeSpecific;
|
||||
@ -50,7 +50,7 @@ async function findBestEmbedStream(
|
||||
embedNum += 1;
|
||||
if (!embed.type) continue;
|
||||
const scraper = getEmbedScraperByType(embed.type);
|
||||
if (!scraper) throw new Error("Type for embed not found: " + embed.type);
|
||||
if (!scraper) throw new Error(`Type for embed not found: ${embed.type}`);
|
||||
|
||||
const eventId = [providerId, scraper.id, embedNum].join("|");
|
||||
|
||||
|
@ -8,7 +8,7 @@ import "./providers/netfilm";
|
||||
import "./providers/m4ufree";
|
||||
|
||||
// embeds
|
||||
import "./embeds/streamm4u"
|
||||
import "./embeds/playm4u"
|
||||
import "./embeds/streamm4u";
|
||||
import "./embeds/playm4u";
|
||||
|
||||
initializeScraperStore();
|
||||
|
@ -21,7 +21,10 @@ registerProvider({
|
||||
}
|
||||
);
|
||||
const foundItem = searchResults.results.find((v: any) => {
|
||||
return compareTitle(v.title, media.meta.title) && v.releaseDate === media.meta.year;
|
||||
return (
|
||||
compareTitle(v.title, media.meta.title) &&
|
||||
v.releaseDate === media.meta.year
|
||||
);
|
||||
});
|
||||
if (!foundItem) throw new Error("No watchable item found");
|
||||
const flixId = foundItem.id;
|
||||
|
@ -1,11 +1,9 @@
|
||||
import { compareTitle } from "@/utils/titleMatch";
|
||||
import { MWEmbedType } from "../helpers/embed";
|
||||
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
import { MWEmbed } from "@/backend/helpers/embed";
|
||||
|
||||
const HOST = 'm4ufree.com';
|
||||
const HOST = "m4ufree.com";
|
||||
const URL_BASE = `https://${HOST}`;
|
||||
const URL_SEARCH = `${URL_BASE}/search`;
|
||||
const URL_AJAX = `${URL_BASE}/ajax`;
|
||||
@ -22,7 +20,7 @@ const REGEX_COOKIES = /XSRF-TOKEN=(.*?);.*laravel_session=(.*?);/;
|
||||
const REGEX_SEASON_EPISODE = /S(\d*)-E(\d*)/;
|
||||
|
||||
function toDom(html: string) {
|
||||
return new DOMParser().parseFromString(html, "text/html")
|
||||
return new DOMParser().parseFromString(html, "text/html");
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
@ -32,9 +30,14 @@ registerProvider({
|
||||
disabled: true, // Disables because the redirector URLs it returns will throw 404 / 403 depending on if you view it in the browser or fetch it respectively. It just does not work.
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, progress, type, episode: episodeId, season: seasonId }) {
|
||||
const season = media.meta.seasons?.find(s => s.id === seasonId)?.number || 1
|
||||
const episode = media.meta.type === MWMediaType.SERIES ? media.meta.seasonData.episodes.find(ep => ep.id === episodeId)?.number || 1 : undefined
|
||||
async scrape({ media, type, episode: episodeId, season: seasonId }) {
|
||||
const season =
|
||||
media.meta.seasons?.find((s) => s.id === seasonId)?.number || 1;
|
||||
const episode =
|
||||
media.meta.type === MWMediaType.SERIES
|
||||
? media.meta.seasonData.episodes.find((ep) => ep.id === episodeId)
|
||||
?.number || 1
|
||||
: undefined;
|
||||
|
||||
const embeds: MWEmbed[] = [];
|
||||
|
||||
@ -43,39 +46,49 @@ registerProvider({
|
||||
responseType: "text" as any,
|
||||
}
|
||||
*/
|
||||
let responseText = await proxiedFetch<string>(`${URL_SEARCH}/${encodeURIComponent(media.meta.title)}.html`);
|
||||
const responseText = await proxiedFetch<string>(
|
||||
`${URL_SEARCH}/${encodeURIComponent(media.meta.title)}.html`
|
||||
);
|
||||
let dom = toDom(responseText);
|
||||
|
||||
const searchResults = [...dom.querySelectorAll('.item')].map(element => {
|
||||
const tooltipText = element.querySelector('.tiptitle p')?.innerHTML;
|
||||
if (!tooltipText) return;
|
||||
const searchResults = [...dom.querySelectorAll(".item")]
|
||||
.map((element) => {
|
||||
const tooltipText = element.querySelector(".tiptitle p")?.innerHTML;
|
||||
if (!tooltipText) return;
|
||||
|
||||
let regexResult = REGEX_TITLE_AND_YEAR.exec(tooltipText);
|
||||
let regexResult = REGEX_TITLE_AND_YEAR.exec(tooltipText);
|
||||
|
||||
if (!regexResult || !regexResult[1] || !regexResult[2]) {
|
||||
return;
|
||||
}
|
||||
if (!regexResult || !regexResult[1] || !regexResult[2]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = regexResult[1];
|
||||
const year = Number(regexResult[2].slice(0, 4)); // * Some media stores the start AND end year. Only need start year
|
||||
const a = element.querySelector('a');
|
||||
if (!a) return;
|
||||
const href = a.href;
|
||||
const title = regexResult[1];
|
||||
const year = Number(regexResult[2].slice(0, 4)); // * Some media stores the start AND end year. Only need start year
|
||||
const a = element.querySelector("a");
|
||||
if (!a) return;
|
||||
const href = a.href;
|
||||
|
||||
regexResult = REGEX_TYPE.exec(href);
|
||||
regexResult = REGEX_TYPE.exec(href);
|
||||
|
||||
if (!regexResult || !regexResult[1]) {
|
||||
return;
|
||||
}
|
||||
if (!regexResult || !regexResult[1]) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scraperDeterminedType = regexResult[1];
|
||||
let scraperDeterminedType = regexResult[1];
|
||||
|
||||
scraperDeterminedType = scraperDeterminedType === 'tvshow' ? 'show' : 'movie'; // * Map to Trakt type
|
||||
scraperDeterminedType =
|
||||
scraperDeterminedType === "tvshow" ? "show" : "movie"; // * Map to Trakt type
|
||||
|
||||
return { type: scraperDeterminedType, title, year, href };
|
||||
}).filter(item => item);
|
||||
return { type: scraperDeterminedType, title, year, href };
|
||||
})
|
||||
.filter((item) => item);
|
||||
|
||||
const mediaInResults = searchResults.find(item => item && item.title === media.meta.title && item.year.toString() === media.meta.year);
|
||||
const mediaInResults = searchResults.find(
|
||||
(item) =>
|
||||
item &&
|
||||
item.title === media.meta.title &&
|
||||
item.year.toString() === media.meta.year
|
||||
);
|
||||
|
||||
if (!mediaInResults) {
|
||||
// * Nothing found
|
||||
@ -84,109 +97,124 @@ registerProvider({
|
||||
};
|
||||
}
|
||||
|
||||
let cookies: string | null = '';
|
||||
const responseTextFromMedia = await proxiedFetch<string>(mediaInResults.href, {
|
||||
onResponse(context) {
|
||||
cookies = context.response.headers.get('X-Set-Cookie')
|
||||
},
|
||||
});
|
||||
let cookies: string | null = "";
|
||||
const responseTextFromMedia = await proxiedFetch<string>(
|
||||
mediaInResults.href,
|
||||
{
|
||||
onResponse(context) {
|
||||
cookies = context.response.headers.get("X-Set-Cookie");
|
||||
},
|
||||
}
|
||||
);
|
||||
dom = toDom(responseTextFromMedia);
|
||||
|
||||
let regexResult = REGEX_COOKIES.exec(cookies);
|
||||
|
||||
if (!regexResult || !regexResult[1] || !regexResult[2]) {
|
||||
// * DO SOMETHING?
|
||||
throw new Error("No regexResults, yikesssssss kinda gross idk")
|
||||
throw new Error("No regexResults, yikesssssss kinda gross idk");
|
||||
}
|
||||
|
||||
const cookieHeader = `XSRF-TOKEN=${regexResult[1]}; laravel_session=${regexResult[2]}`;
|
||||
|
||||
const token = dom.querySelector('meta[name="csrf-token"]')?.getAttribute("content");
|
||||
const token = dom
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute("content");
|
||||
if (!token) return { embeds };
|
||||
|
||||
if (type === MWMediaType.SERIES) {
|
||||
// * Get the season/episode data
|
||||
const episodes = [...dom.querySelectorAll('.episode')].map(element => {
|
||||
regexResult = REGEX_SEASON_EPISODE.exec(element.innerHTML);
|
||||
const episodes = [...dom.querySelectorAll(".episode")]
|
||||
.map((element) => {
|
||||
regexResult = REGEX_SEASON_EPISODE.exec(element.innerHTML);
|
||||
|
||||
if (!regexResult || !regexResult[1] || !regexResult[2]) {
|
||||
return;
|
||||
}
|
||||
if (!regexResult || !regexResult[1] || !regexResult[2]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const episode = Number(regexResult[1]);
|
||||
const season = Number(regexResult[2]);
|
||||
const newEpisode = Number(regexResult[1]);
|
||||
const newSeason = Number(regexResult[2]);
|
||||
|
||||
return {
|
||||
id: element.getAttribute('idepisode'),
|
||||
episode: episode,
|
||||
season: season
|
||||
};
|
||||
}).filter(item => item);
|
||||
return {
|
||||
id: element.getAttribute("idepisode"),
|
||||
episode: newEpisode,
|
||||
season: newSeason,
|
||||
};
|
||||
})
|
||||
.filter((item) => item);
|
||||
|
||||
const ep = episodes.find(ep => ep && ep.episode === episode && ep.season === season);
|
||||
if (!ep) return { embeds }
|
||||
const ep = episodes.find(
|
||||
(newEp) => newEp && newEp.episode === episode && newEp.season === season
|
||||
);
|
||||
if (!ep) return { embeds };
|
||||
|
||||
const form = `idepisode=${ep.id}&_token=${token}`;
|
||||
|
||||
let response = await proxiedFetch<string>(URL_AJAX_TV, {
|
||||
method: 'POST',
|
||||
const response = await proxiedFetch<string>(URL_AJAX_TV, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Accept': '*/*',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Accept-Language': "en-US,en;q=0.9",
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Sec-CH-UA': '"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
|
||||
'Sec-CH-UA-Mobile': '?0',
|
||||
'Sec-CH-UA-Platform': '"Linux"',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'X-Cookie': cookieHeader,
|
||||
'X-Origin': URL_BASE,
|
||||
'X-Referer': mediaInResults.href
|
||||
Accept: "*/*",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Sec-CH-UA":
|
||||
'"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
|
||||
"Sec-CH-UA-Mobile": "?0",
|
||||
"Sec-CH-UA-Platform": '"Linux"',
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"X-Cookie": cookieHeader,
|
||||
"X-Origin": URL_BASE,
|
||||
"X-Referer": mediaInResults.href,
|
||||
},
|
||||
body: form
|
||||
body: form,
|
||||
});
|
||||
|
||||
dom = toDom(response);
|
||||
}
|
||||
|
||||
const servers = [...dom.querySelectorAll('.singlemv')].map(element => element.getAttribute('data'));
|
||||
const servers = [...dom.querySelectorAll(".singlemv")].map((element) =>
|
||||
element.getAttribute("data")
|
||||
);
|
||||
|
||||
for (const server of servers) {
|
||||
const form = `m4u=${server}&_token=${token}`;
|
||||
|
||||
const response = await proxiedFetch<string>(URL_AJAX, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Accept': '*/*',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Accept-Language': "en-US,en;q=0.9",
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Sec-CH-UA': '"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
|
||||
'Sec-CH-UA-Mobile': '?0',
|
||||
'Sec-CH-UA-Platform': '"Linux"',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'X-Cookie': cookieHeader,
|
||||
'X-Origin': URL_BASE,
|
||||
'X-Referer': mediaInResults.href
|
||||
Accept: "*/*",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Sec-CH-UA":
|
||||
'"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
|
||||
"Sec-CH-UA-Mobile": "?0",
|
||||
"Sec-CH-UA-Platform": '"Linux"',
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"X-Cookie": cookieHeader,
|
||||
"X-Origin": URL_BASE,
|
||||
"X-Referer": mediaInResults.href,
|
||||
},
|
||||
body: form
|
||||
body: form,
|
||||
});
|
||||
|
||||
const dom = toDom(response);
|
||||
const serverDom = toDom(response);
|
||||
|
||||
const link = dom.querySelector('iframe')?.src;
|
||||
const link = serverDom.querySelector("iframe")?.src;
|
||||
|
||||
const getEmbedType = (url: string) => {
|
||||
if (url.startsWith("https://streamm4u.club")) return MWEmbedType.STREAMM4U
|
||||
if (url.startsWith("https://play.playm4u.xyz")) return MWEmbedType.PLAYM4U
|
||||
if (url.startsWith("https://streamm4u.club"))
|
||||
return MWEmbedType.STREAMM4U;
|
||||
if (url.startsWith("https://play.playm4u.xyz"))
|
||||
return MWEmbedType.PLAYM4U;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!link) continue;
|
||||
|
||||
@ -194,15 +222,14 @@ registerProvider({
|
||||
if (embedType) {
|
||||
embeds.push({
|
||||
url: link,
|
||||
type: embedType
|
||||
})
|
||||
};
|
||||
type: embedType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(embeds);
|
||||
return {
|
||||
embeds,
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -131,7 +131,8 @@ registerProvider({
|
||||
|
||||
const superstreamEntry = searchRes.find(
|
||||
(res: any) =>
|
||||
compareTitle(res.title, media.meta.title) && res.year === Number(media.meta.year)
|
||||
compareTitle(res.title, media.meta.title) &&
|
||||
res.year === Number(media.meta.year)
|
||||
);
|
||||
|
||||
if (!superstreamEntry) throw new Error("No entry found on SuperStream");
|
||||
|
14
src/components/Overlay.tsx
Normal file
14
src/components/Overlay.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
export function Overlay(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<body data-no-scroll />
|
||||
</Helmet>
|
||||
<div className="fixed inset-0 z-[99999] flex h-full w-full items-center justify-center bg-[rgba(8,6,18,0.85)]">
|
||||
{props.children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -67,7 +67,7 @@ export function SearchBarInput(props: SearchBarProps) {
|
||||
id: MWMediaType.SERIES,
|
||||
name: t("searchBar.series"),
|
||||
icon: Icons.CLAPPER_BOARD,
|
||||
}
|
||||
},
|
||||
]}
|
||||
onClick={() => setDropdownOpen((old) => !old)}
|
||||
>
|
||||
|
@ -10,7 +10,7 @@ export interface EditButtonProps {
|
||||
}
|
||||
|
||||
export function EditButton(props: EditButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
const [parent] = useAutoAnimate<HTMLSpanElement>();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
@ -24,7 +24,9 @@ export function EditButton(props: EditButtonProps) {
|
||||
>
|
||||
<span ref={parent}>
|
||||
{props.editing ? (
|
||||
<span className="mx-4 whitespace-nowrap">{t("media.stopEditing")}</span>
|
||||
<span className="mx-4 whitespace-nowrap">
|
||||
{t("media.stopEditing")}
|
||||
</span>
|
||||
) : (
|
||||
<Icon icon={Icons.EDIT} />
|
||||
)}
|
||||
|
@ -36,12 +36,13 @@ interface ErrorMessageProps {
|
||||
}
|
||||
|
||||
export function ErrorMessage(props: ErrorMessageProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${props.localSize ? "h-full" : "min-h-screen"
|
||||
} flex w-full flex-col items-center justify-center px-4 py-12`}
|
||||
className={`${
|
||||
props.localSize ? "h-full" : "min-h-screen"
|
||||
} flex w-full flex-col items-center justify-center px-4 py-12`}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-start text-center">
|
||||
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
|
||||
|
25
src/components/layout/Modal.tsx
Normal file
25
src/components/layout/Modal.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Overlay } from "@/components/Overlay";
|
||||
import { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ModalFrame(props: { children?: ReactNode }) {
|
||||
return <Overlay>{props.children}</Overlay>;
|
||||
}
|
||||
|
||||
export function Modal(props: Props) {
|
||||
if (!props.show) return null;
|
||||
return createPortal(<ModalFrame>{props.children}</ModalFrame>, document.body);
|
||||
}
|
||||
|
||||
export function ModalCard(props: { children?: ReactNode }) {
|
||||
return (
|
||||
<div className="relative w-4/5 max-w-[645px] overflow-hidden rounded-lg bg-denim-200 px-10 py-16">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -35,12 +35,14 @@ function MediaCardContent({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${canLink ? "hover:bg-opacity-100" : ""
|
||||
}`}
|
||||
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
|
||||
canLink ? "hover:bg-opacity-100" : ""
|
||||
}`}
|
||||
>
|
||||
<article
|
||||
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${canLink ? "group-hover:scale-95" : ""
|
||||
}`}
|
||||
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${
|
||||
canLink ? "group-hover:scale-95" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100 group-hover:rounded-lg"
|
||||
@ -53,7 +55,7 @@ function MediaCardContent({
|
||||
<p className="text-center text-xs font-bold text-slate-400 transition-colors group-hover:text-white">
|
||||
{t("seasons.seasonAndEpisode", {
|
||||
season: series.season,
|
||||
episode: series.episode
|
||||
episode: series.episode,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
@ -62,12 +64,14 @@ function MediaCardContent({
|
||||
{percentage !== undefined ? (
|
||||
<>
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${canLink ? "group-hover:from-denim-100" : ""
|
||||
}`}
|
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||
canLink ? "group-hover:from-denim-100" : ""
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${canLink ? "group-hover:from-denim-100" : ""
|
||||
}`}
|
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||
canLink ? "group-hover:from-denim-100" : ""
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 p-3">
|
||||
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600">
|
||||
@ -83,8 +87,9 @@ function MediaCardContent({
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${closable ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${
|
||||
closable ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
>
|
||||
<IconPatch
|
||||
clickable
|
||||
@ -99,10 +104,7 @@ function MediaCardContent({
|
||||
</h1>
|
||||
<DotList
|
||||
className="text-xs"
|
||||
content={[
|
||||
t(`media.${media.type}`),
|
||||
media.year,
|
||||
]}
|
||||
content={[t(`media.${media.type}`), media.year]}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
|
@ -44,10 +44,9 @@ const LazyLoadedApp = React.lazy(async () => {
|
||||
|
||||
function TheRouter(props: { children: ReactNode }) {
|
||||
const normalRouter = conf().NORMAL_ROUTER;
|
||||
|
||||
if (normalRouter)
|
||||
return <BrowserRouter children={props.children} />
|
||||
return <HashRouter children={props.children} />
|
||||
|
||||
if (normalRouter) return <BrowserRouter>{props.children}</BrowserRouter>;
|
||||
return <HashRouter>{props.children}</HashRouter>;
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
|
@ -49,11 +49,13 @@ async function getMetas(
|
||||
searchQuery: `${item.title} ${year}`,
|
||||
type: item.mediaType,
|
||||
});
|
||||
const relevantItem = data.find((res) =>
|
||||
yearsAreClose(Number(res.year), year) && compareTitle(res.title, item.title)
|
||||
const relevantItem = data.find(
|
||||
(res) =>
|
||||
yearsAreClose(Number(res.year), year) &&
|
||||
compareTitle(res.title, item.title)
|
||||
);
|
||||
if (!relevantItem) {
|
||||
console.error("No item found for migration: " + item.title);
|
||||
console.error(`No item found for migration: ${item.title}`);
|
||||
return;
|
||||
}
|
||||
return {
|
||||
@ -188,7 +190,10 @@ export async function migrateV2Videos(old: OldData) {
|
||||
},
|
||||
progress: oldWatched.progress,
|
||||
percentage: oldWatched.percentage,
|
||||
watchedAt: now + Number(oldWatched.seasonId) * 1000 + Number(oldWatched.episodeId), // There was no watchedAt in V2
|
||||
watchedAt:
|
||||
now +
|
||||
Number(oldWatched.seasonId) * 1000 +
|
||||
Number(oldWatched.episodeId), // There was no watchedAt in V2
|
||||
// JANK ALERT: Put watchedAt in the future to show last episode as most recently
|
||||
};
|
||||
|
||||
|
@ -1,7 +1,11 @@
|
||||
function normalizeTitle(title: string): string {
|
||||
return title.trim().toLowerCase().replace(/[\'\"\:]/g, "").replace(/[^a-zA-Z0-9]+/g, "_");
|
||||
return title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['":]/g, "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "_");
|
||||
}
|
||||
|
||||
export function compareTitle(a: string, b: string): boolean {
|
||||
return normalizeTitle(a) === normalizeTitle(b);
|
||||
return normalizeTitle(a) === normalizeTitle(b);
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function CaptionsSelectionAction(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const controls = useControls(descriptor);
|
||||
const { isMobile } = useIsMobile();
|
||||
@ -22,7 +22,7 @@ export function CaptionsSelectionAction(props: Props) {
|
||||
<PopoutAnchor for="captions">
|
||||
<VideoPlayerIconButton
|
||||
className={props.className}
|
||||
text={isMobile ? t("videoPlayer.buttons.captions") as string : ""}
|
||||
text={isMobile ? (t("videoPlayer.buttons.captions") as string) : ""}
|
||||
wide={isMobile}
|
||||
onClick={() => controls.openPopout("captions")}
|
||||
icon={Icons.CAPTIONS}
|
||||
|
@ -9,7 +9,9 @@ export function PageTitleAction() {
|
||||
|
||||
if (!meta) return null;
|
||||
|
||||
const title = isSeries ? `${meta.meta.title} - ${humanizedEpisodeId}` : meta.meta.title;
|
||||
const title = isSeries
|
||||
? `${meta.meta.title} - ${humanizedEpisodeId}`
|
||||
: meta.meta.title;
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
|
@ -13,7 +13,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function SeriesSelectionAction(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const meta = useMeta(descriptor);
|
||||
const videoInterface = useInterface(descriptor);
|
||||
|
@ -11,7 +11,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function SourceSelectionAction(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const videoInterface = useInterface(descriptor);
|
||||
const controls = useControls(descriptor);
|
||||
|
@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useCurrentSeriesEpisodeInfo(descriptor: string) {
|
||||
const meta = useMeta(descriptor);
|
||||
const {t} = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const currentSeasonInfo = useMemo(() => {
|
||||
return meta?.seasons?.find(
|
||||
@ -24,10 +24,10 @@ export function useCurrentSeriesEpisodeInfo(descriptor: string) {
|
||||
);
|
||||
|
||||
if (!isSeries) return { isSeries: false };
|
||||
|
||||
|
||||
const humanizedEpisodeId = t("videoPlayer.seasonAndEpisode", {
|
||||
season: currentSeasonInfo?.number,
|
||||
episode: currentEpisodeInfo?.number
|
||||
episode: currentEpisodeInfo?.number,
|
||||
});
|
||||
|
||||
return {
|
||||
|
@ -15,7 +15,7 @@ function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
|
||||
}
|
||||
|
||||
export function CaptionSelectionPopout() {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const meta = useMeta(descriptor);
|
||||
|
@ -11,14 +11,14 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useWatchedContext } from "@/state/watched";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
|
||||
export function EpisodeSelectionPopout() {
|
||||
const params = useParams<{
|
||||
media: string;
|
||||
}>();
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const meta = useMeta(descriptor);
|
||||
@ -61,7 +61,7 @@ export function EpisodeSelectionPopout() {
|
||||
// race condition, jank solution but it works.
|
||||
setTimeout(() => {
|
||||
controls.setCurrentEpisode(seasonId, episodeId);
|
||||
}, 100)
|
||||
}, 100);
|
||||
},
|
||||
[controls]
|
||||
);
|
||||
@ -141,15 +141,15 @@ export function EpisodeSelectionPopout() {
|
||||
>
|
||||
{currentSeasonInfo
|
||||
? meta?.seasons?.map?.((season) => (
|
||||
<PopoutListEntry
|
||||
key={season.id}
|
||||
active={meta?.episode?.seasonId === season.id}
|
||||
onClick={() => setSeason(season.id)}
|
||||
isOnDarkBackground
|
||||
>
|
||||
{season.title}
|
||||
</PopoutListEntry>
|
||||
))
|
||||
<PopoutListEntry
|
||||
key={season.id}
|
||||
active={meta?.episode?.seasonId === season.id}
|
||||
onClick={() => setSeason(season.id)}
|
||||
isOnDarkBackground
|
||||
>
|
||||
{season.title}
|
||||
</PopoutListEntry>
|
||||
))
|
||||
: "No season"}
|
||||
</PopoutSection>
|
||||
<PopoutSection className="relative h-full overflow-y-auto">
|
||||
@ -166,7 +166,7 @@ export function EpisodeSelectionPopout() {
|
||||
/>
|
||||
<p className="mt-6 w-full text-center">
|
||||
{t("videoPLayer.popouts.errors.loadingWentWrong", {
|
||||
seasonTitle: currentSeasonInfo?.title?.toLowerCase()
|
||||
seasonTitle: currentSeasonInfo?.title?.toLowerCase(),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
@ -175,29 +175,29 @@ export function EpisodeSelectionPopout() {
|
||||
<div>
|
||||
{currentSeasonEpisodes && currentSeasonInfo
|
||||
? currentSeasonEpisodes.map((e) => (
|
||||
<PopoutListEntry
|
||||
key={e.id}
|
||||
active={e.id === meta?.episode?.episodeId}
|
||||
onClick={() => {
|
||||
if (e.id === meta?.episode?.episodeId)
|
||||
controls.closePopout();
|
||||
else setCurrent(currentSeasonInfo.id, e.id);
|
||||
}}
|
||||
percentageCompleted={
|
||||
watched.items.find(
|
||||
(item) =>
|
||||
item.item?.series?.seasonId ===
|
||||
currentSeasonInfo.id &&
|
||||
item.item?.series?.episodeId === e.id
|
||||
)?.percentage
|
||||
}
|
||||
>
|
||||
{t("videoPlayer.popouts.episode", {
|
||||
index: e.number,
|
||||
title: e.title
|
||||
})}
|
||||
</PopoutListEntry>
|
||||
))
|
||||
<PopoutListEntry
|
||||
key={e.id}
|
||||
active={e.id === meta?.episode?.episodeId}
|
||||
onClick={() => {
|
||||
if (e.id === meta?.episode?.episodeId)
|
||||
controls.closePopout();
|
||||
else setCurrent(currentSeasonInfo.id, e.id);
|
||||
}}
|
||||
percentageCompleted={
|
||||
watched.items.find(
|
||||
(item) =>
|
||||
item.item?.series?.seasonId ===
|
||||
currentSeasonInfo.id &&
|
||||
item.item?.series?.episodeId === e.id
|
||||
)?.percentage
|
||||
}
|
||||
>
|
||||
{t("videoPlayer.popouts.episode", {
|
||||
index: e.number,
|
||||
title: e.title,
|
||||
})}
|
||||
</PopoutListEntry>
|
||||
))
|
||||
: "No episodes"}
|
||||
</div>
|
||||
)}
|
||||
|
@ -7,12 +7,15 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { MWStream } from "@/backend/helpers/streams";
|
||||
import { getEmbedScraperByType, getProviders } from "@/backend/helpers/register";
|
||||
import {
|
||||
getEmbedScraperByType,
|
||||
getProviders,
|
||||
} from "@/backend/helpers/register";
|
||||
import { runEmbedScraper, runProvider } from "@/backend/helpers/run";
|
||||
import { MWProviderScrapeResult } from "@/backend/helpers/provider";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
|
||||
interface EmbedEntryProps {
|
||||
name: string;
|
||||
@ -24,35 +27,39 @@ interface EmbedEntryProps {
|
||||
export function EmbedEntry(props: EmbedEntryProps) {
|
||||
const [scrapeEmbed, loading, error] = useLoading(async () => {
|
||||
const scraper = getEmbedScraperByType(props.type);
|
||||
if (!scraper) throw new Error("Embed scraper not found")
|
||||
if (!scraper) throw new Error("Embed scraper not found");
|
||||
const stream = await runEmbedScraper(scraper, {
|
||||
progress: () => { }, // no progress tracking for inline scraping
|
||||
progress: () => {}, // no progress tracking for inline scraping
|
||||
url: props.url,
|
||||
})
|
||||
});
|
||||
props.onSelect(stream);
|
||||
});
|
||||
|
||||
return (<PopoutListEntry
|
||||
isOnDarkBackground
|
||||
loading={loading}
|
||||
errored={!!error}
|
||||
onClick={() => {
|
||||
scrapeEmbed();
|
||||
}}
|
||||
>
|
||||
{props.name}
|
||||
</PopoutListEntry>)
|
||||
return (
|
||||
<PopoutListEntry
|
||||
isOnDarkBackground
|
||||
loading={loading}
|
||||
errored={!!error}
|
||||
onClick={() => {
|
||||
scrapeEmbed();
|
||||
}}
|
||||
>
|
||||
{props.name}
|
||||
</PopoutListEntry>
|
||||
);
|
||||
}
|
||||
|
||||
export function SourceSelectionPopout() {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const controls = useControls(descriptor);
|
||||
const meta = useMeta(descriptor);
|
||||
const providers = useMemo(
|
||||
() =>
|
||||
meta ? getProviders().filter((v) => v.type.includes(meta.meta.meta.type)) : [],
|
||||
meta
|
||||
? getProviders().filter((v) => v.type.includes(meta.meta.meta.type))
|
||||
: [],
|
||||
[meta]
|
||||
);
|
||||
|
||||
@ -71,7 +78,7 @@ export function SourceSelectionPopout() {
|
||||
if (!meta) throw new Error("need meta");
|
||||
return runProvider(theProvider, {
|
||||
media: meta.meta,
|
||||
progress: () => { },
|
||||
progress: () => {},
|
||||
type: meta.meta.meta.type,
|
||||
episode: meta.episode?.episodeId as any,
|
||||
season: meta.episode?.seasonId as any,
|
||||
@ -110,13 +117,13 @@ export function SourceSelectionPopout() {
|
||||
const realStream = v.stream;
|
||||
if (!realStream) {
|
||||
const embed = v?.embeds[0];
|
||||
if (!embed) throw new Error("Embed scraper not found")
|
||||
if (!embed) throw new Error("Embed scraper not found");
|
||||
const scraper = getEmbedScraperByType(embed.type);
|
||||
if (!scraper) throw new Error("Embed scraper not found")
|
||||
if (!scraper) throw new Error("Embed scraper not found");
|
||||
const stream = await runEmbedScraper(scraper, {
|
||||
progress: () => { }, // no progress tracking for inline scraping
|
||||
progress: () => {}, // no progress tracking for inline scraping
|
||||
url: embed.url,
|
||||
})
|
||||
});
|
||||
selectSource(stream);
|
||||
return;
|
||||
}
|
||||
@ -142,28 +149,30 @@ export function SourceSelectionPopout() {
|
||||
const embeds = scrapeResult?.embeds || [];
|
||||
|
||||
// Count embed types to determine if it should show a number behind the name
|
||||
const embedsPerType: Record<string, (MWEmbed & { displayName: string })[]> = {}
|
||||
const embedsPerType: Record<string, (MWEmbed & { displayName: string })[]> =
|
||||
{};
|
||||
for (const embed of embeds) {
|
||||
if (!embed.type) continue;
|
||||
if (!embedsPerType[embed.type]) embedsPerType[embed.type] = [];
|
||||
embedsPerType[embed.type].push({
|
||||
...embed,
|
||||
displayName: embed.type
|
||||
})
|
||||
displayName: embed.type,
|
||||
});
|
||||
}
|
||||
|
||||
const embedsRes = Object.entries(embedsPerType).flatMap(([type, entries]) => {
|
||||
if (entries.length > 1) return entries.map((embed, i) => ({
|
||||
...embed,
|
||||
displayName: `${embed.type} ${i + 1}`
|
||||
}))
|
||||
const embedsRes = Object.entries(embedsPerType).flatMap(([_, entries]) => {
|
||||
if (entries.length > 1)
|
||||
return entries.map((embed, i) => ({
|
||||
...embed,
|
||||
displayName: `${embed.type} ${i + 1}`,
|
||||
}));
|
||||
return entries;
|
||||
})
|
||||
});
|
||||
|
||||
console.log(embedsRes)
|
||||
console.log(embedsRes);
|
||||
|
||||
return embedsRes;
|
||||
}, [scrapeResult?.embeds])
|
||||
}, [scrapeResult?.embeds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -234,27 +243,31 @@ export function SourceSelectionPopout() {
|
||||
Native source
|
||||
</PopoutListEntry>
|
||||
) : null}
|
||||
{(visibleEmbeds?.length || 0) > 0 ? visibleEmbeds?.map((v) => (
|
||||
<EmbedEntry
|
||||
type={v.type}
|
||||
name={v.displayName ?? ""}
|
||||
key={v.url}
|
||||
url={v.url}
|
||||
onSelect={(stream) => {
|
||||
selectSource(stream);
|
||||
}}
|
||||
/>
|
||||
)) : (<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex flex-col flex-wrap items-center text-slate-400">
|
||||
<IconPatch
|
||||
icon={Icons.EYE_SLASH}
|
||||
className="text-xl text-bink-600"
|
||||
{(visibleEmbeds?.length || 0) > 0 ? (
|
||||
visibleEmbeds?.map((v) => (
|
||||
<EmbedEntry
|
||||
type={v.type}
|
||||
name={v.displayName ?? ""}
|
||||
key={v.url}
|
||||
url={v.url}
|
||||
onSelect={(stream) => {
|
||||
selectSource(stream);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-6 w-full text-center">
|
||||
{t("videoPlayer.popouts.noEmbeds")}
|
||||
</p>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex flex-col flex-wrap items-center text-slate-400">
|
||||
<IconPatch
|
||||
icon={Icons.EYE_SLASH}
|
||||
className="text-xl text-bink-600"
|
||||
/>
|
||||
<p className="mt-6 w-full text-center">
|
||||
{t("videoPlayer.popouts.noEmbeds")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PopoutSection>
|
||||
|
@ -4,7 +4,6 @@ import {
|
||||
MWStreamType,
|
||||
} from "@/backend/helpers/streams";
|
||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import Hls from "hls.js";
|
||||
import { VideoPlayerStateProvider } from "./providers/providerTypes";
|
||||
|
||||
|
@ -1,14 +1,11 @@
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
||||
import { Link } from "@/components/text/Link";
|
||||
import { useGoBack } from "@/hooks/useGoBack";
|
||||
import { conf } from "@/setup/config";
|
||||
import { VideoPlayerHeader } from "@/video/components/parts/VideoPlayerHeader";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function MediaFetchErrorView() {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
const goBack = useGoBack();
|
||||
|
||||
return (
|
||||
@ -20,9 +17,7 @@ export function MediaFetchErrorView() {
|
||||
<VideoPlayerHeader onClick={goBack} />
|
||||
</div>
|
||||
<ErrorMessage>
|
||||
<p className="my-6 max-w-lg">
|
||||
{t("media.errors.mediaFailed")}
|
||||
</p>
|
||||
<p className="my-6 max-w-lg">{t("media.errors.mediaFailed")}</p>
|
||||
</ErrorMessage>
|
||||
</div>
|
||||
);
|
||||
|
@ -19,13 +19,13 @@ import { ProgressListenerController } from "@/video/components/controllers/Progr
|
||||
import { VideoPlayerMeta } from "@/video/state/types";
|
||||
import { SeriesController } from "@/video/components/controllers/SeriesController";
|
||||
import { useWatchedItem } from "@/state/watched";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MediaFetchErrorView } from "./MediaErrorView";
|
||||
import { MediaScrapeLog } from "./MediaScrapeLog";
|
||||
import { NotFoundMedia, NotFoundWrapper } from "../notfound/NotFoundView";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function MediaViewLoading(props: { onGoBack(): void }) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen items-center justify-center">
|
||||
@ -37,7 +37,9 @@ function MediaViewLoading(props: { onGoBack(): void }) {
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<Loading className="mb-4" />
|
||||
<p className="mb-8 text-denim-700">{t("videoPlayer.findingBestVideo")}</p>
|
||||
<p className="mb-8 text-denim-700">
|
||||
{t("videoPlayer.findingBestVideo")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -51,7 +53,7 @@ interface MediaViewScrapingProps {
|
||||
}
|
||||
function MediaViewScraping(props: MediaViewScrapingProps) {
|
||||
const { eventLog, stream, pending } = useScrape(props.meta, props.selected);
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (stream) {
|
||||
@ -78,14 +80,13 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
|
||||
) : (
|
||||
<>
|
||||
<IconPatch icon={Icons.EYE_SLASH} className="mb-8 text-bink-700" />
|
||||
<p className="mb-8 text-denim-700">
|
||||
{t("videoPlayer.noVideos")}
|
||||
</p>
|
||||
<p className="mb-8 text-denim-700">{t("videoPlayer.noVideos")}</p>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={`flex flex-col items-center transition-opacity duration-200 ${pending ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
className={`flex flex-col items-center transition-opacity duration-200 ${
|
||||
pending ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<MediaScrapeLog events={eventLog} />
|
||||
</div>
|
||||
|
@ -13,7 +13,7 @@ export function NotFoundWrapper(props: {
|
||||
children?: ReactNode;
|
||||
video?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
const goBack = useGoBack();
|
||||
|
||||
return (
|
||||
|
@ -2,60 +2,66 @@ import { useEffect, useState } from "react";
|
||||
import pako from "pako";
|
||||
|
||||
function fromBinary(str: string): Uint8Array {
|
||||
let result = new Uint8Array(str.length);
|
||||
[...str].forEach((char, i) => {
|
||||
result[i] = char.charCodeAt(0);
|
||||
});
|
||||
return result;
|
||||
const result = new Uint8Array(str.length);
|
||||
[...str].forEach((char, i) => {
|
||||
result[i] = char.charCodeAt(0);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
export function V2MigrationView() {
|
||||
const [done, setDone] = useState(false);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search ?? "");
|
||||
if (!params.has("m-time") || !params.has("m-data")) {
|
||||
// migration params missing, just redirect
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
const [done, setDone] = useState(false);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search ?? "");
|
||||
if (!params.has("m-time") || !params.has("m-data")) {
|
||||
// migration params missing, just redirect
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(pako.inflate(fromBinary(atob(params.get("m-data") as string)), { to: "string" }));
|
||||
const timeOfMigration = new Date(params.get("m-time") as string);
|
||||
const data = JSON.parse(
|
||||
pako.inflate(fromBinary(atob(params.get("m-data") as string)), {
|
||||
to: "string",
|
||||
})
|
||||
);
|
||||
const timeOfMigration = new Date(params.get("m-time") as string);
|
||||
|
||||
const savedTime = localStorage.getItem("mw-migration-date");
|
||||
if (savedTime) {
|
||||
if (new Date(savedTime) >= timeOfMigration) {
|
||||
// has already migrated this or something newer, skip
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// restore migration data
|
||||
if (data.bookmarks)
|
||||
localStorage.setItem("mw-bookmarks", JSON.stringify(data.bookmarks))
|
||||
if (data.videoProgress)
|
||||
localStorage.setItem("video-progress", JSON.stringify(data.videoProgress))
|
||||
localStorage.setItem("mw-migration-date", timeOfMigration.toISOString())
|
||||
|
||||
// finished
|
||||
const savedTime = localStorage.getItem("mw-migration-date");
|
||||
if (savedTime) {
|
||||
if (new Date(savedTime) >= timeOfMigration) {
|
||||
// has already migrated this or something newer, skip
|
||||
setDone(true);
|
||||
}, [])
|
||||
|
||||
// redirect when done
|
||||
useEffect(() => {
|
||||
if (!done) return;
|
||||
const newUrl = new URL(window.location.href);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newParams = [] as string[];
|
||||
newUrl.searchParams.forEach((_, key)=>newParams.push(key));
|
||||
newParams.forEach(v => newUrl.searchParams.delete(v))
|
||||
// restore migration data
|
||||
if (data.bookmarks)
|
||||
localStorage.setItem("mw-bookmarks", JSON.stringify(data.bookmarks));
|
||||
if (data.videoProgress)
|
||||
localStorage.setItem(
|
||||
"video-progress",
|
||||
JSON.stringify(data.videoProgress)
|
||||
);
|
||||
localStorage.setItem("mw-migration-date", timeOfMigration.toISOString());
|
||||
|
||||
newUrl.hash = "";
|
||||
// finished
|
||||
setDone(true);
|
||||
}, []);
|
||||
|
||||
window.location.href = newUrl.toString();
|
||||
}, [done])
|
||||
// redirect when done
|
||||
useEffect(() => {
|
||||
if (!done) return;
|
||||
const newUrl = new URL(window.location.href);
|
||||
|
||||
return null;
|
||||
const newParams = [] as string[];
|
||||
newUrl.searchParams.forEach((_, key) => newParams.push(key));
|
||||
newParams.forEach((v) => newUrl.searchParams.delete(v));
|
||||
|
||||
newUrl.hash = "";
|
||||
|
||||
window.location.href = newUrl.toString();
|
||||
}, [done]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
@ -1,17 +1,18 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import { MWQuery } from "@/backend/metadata/types";
|
||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||
|
||||
export function SearchLoadingView() {
|
||||
const { t } = useTranslation();
|
||||
const [query] = useSearchQuery()
|
||||
const [query] = useSearchQuery();
|
||||
return (
|
||||
<>
|
||||
<Loading
|
||||
className="mt-40 mb-24 "
|
||||
text={t(`search.loading_${query.type}`) || t("search.loading") || "Fetching your favourite shows..."}
|
||||
/>
|
||||
</>
|
||||
<Loading
|
||||
className="mt-40 mb-24 "
|
||||
text={
|
||||
t(`search.loading_${query.type}`) ||
|
||||
t("search.loading") ||
|
||||
"Fetching your favourite shows..."
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import Sticky from "react-stickynode";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Navigation } from "@/components/layout/Navigation";
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import loadVersion from "vite-plugin-package-version";
|
||||
import checker from 'vite-plugin-checker'
|
||||
import checker from "vite-plugin-checker";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
@ -10,7 +10,14 @@ export default defineConfig({
|
||||
loadVersion(),
|
||||
checker({
|
||||
typescript: true, // check typescript build errors in dev server
|
||||
})
|
||||
eslint: {
|
||||
// check lint errors in dev server
|
||||
lintCommand: "eslint --ext .tsx,.ts src",
|
||||
dev: {
|
||||
logLevel: ["error"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
Loading…
Reference in New Issue
Block a user