mirror of
https://github.com/movie-web/movie-web.git
synced 2025-09-13 18:13:24 +00:00
Compare commits
58 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
8200079af7 | ||
|
dcb5d2f068 | ||
|
99e47f16ea | ||
|
6fb76908ae | ||
|
a718abdcdd | ||
|
106290070a | ||
|
433d618096 | ||
|
af954af36c | ||
|
41979712c3 | ||
|
9b62b55fbb | ||
|
52598599e7 | ||
|
cccc84624a | ||
|
d54921900b | ||
|
2a4bc7349c | ||
|
7b641c61cd | ||
|
3a7b05264d | ||
|
a1e3d98538 | ||
|
3ed5dcfc15 | ||
|
71235f5174 | ||
|
0d79a677a0 | ||
|
a34d245e2b | ||
|
8b8cbc8cc9 | ||
|
5ee4f013ff | ||
|
99a3e6db69 | ||
|
7d3e1c0943 | ||
|
2cfd7e64a2 | ||
|
d6def996bf | ||
|
8bba2961b4 | ||
|
da05a2597e | ||
|
d40076e950 | ||
|
bb4a6d8a1e | ||
|
7007f030e1 | ||
|
24fa1c449f | ||
|
591b1d3bc5 | ||
|
c162f15496 | ||
|
2650707d2c | ||
|
a0a51c898a | ||
|
43c8da9003 | ||
|
1472b21600 | ||
|
2424cdfc9e | ||
|
2239c186a5 | ||
|
0c2df2cd3c | ||
|
b26b0715bd | ||
|
7b75c36d21 | ||
|
e52b29a1a1 | ||
|
12c245b2da | ||
c5251401e7 | |||
41fd23cf20 | |||
c330112dbc | |||
84b8a67cea | |||
|
546b008b2e | ||
|
b9b0380dfe | ||
|
c472e7f7b8 | ||
|
2eab07b8b6 | ||
|
5d8f03b859 | ||
|
2178057633 | ||
|
9e961223f6 | ||
|
c2b52d3db8 |
@@ -8,7 +8,7 @@
|
||||
<a href="https://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
||||
</p>
|
||||
|
||||
movie-web is a web app for watching movies easily. Check it out at **[movie.squeezebox.dev](https://movie.squeezebox.dev)**.
|
||||
movie-web is a web app for watching movies easily. Check it out at **[movie-web.app](https://movie-web.app)**.
|
||||
|
||||
This service works by displaying video files from third-party providers inside an intuitive and aesthetic user interface.
|
||||
|
||||
|
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "movie-web",
|
||||
"version": "3.0.10",
|
||||
"version": "3.0.13",
|
||||
"private": true,
|
||||
"homepage": "https://movie.squeezebox.dev",
|
||||
"homepage": "https://movie-web.app",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"@react-spring/web": "^9.7.1",
|
||||
"@sentry/integrations": "^7.49.0",
|
||||
"@sentry/react": "^7.49.0",
|
||||
"@use-gesture/react": "^10.2.24",
|
||||
"core-js": "^3.29.1",
|
||||
"crypto-js": "^4.1.1",
|
||||
|
@@ -4,6 +4,10 @@ import DOMPurify from "dompurify";
|
||||
import { parse, detect, list } from "subsrt-ts";
|
||||
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||
|
||||
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 const sanitize = DOMPurify.sanitize;
|
||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
|
@@ -1,11 +1,12 @@
|
||||
import { initializeScraperStore } from "./helpers/register";
|
||||
|
||||
// providers
|
||||
import "./providers/gdriveplayer";
|
||||
// import "./providers/gdriveplayer";
|
||||
import "./providers/flixhq";
|
||||
import "./providers/superstream";
|
||||
import "./providers/netfilm";
|
||||
import "./providers/m4ufree";
|
||||
import "./providers/hdwatched";
|
||||
|
||||
// embeds
|
||||
import "./embeds/streamm4u";
|
||||
|
196
src/backend/providers/hdwatched.ts
Normal file
196
src/backend/providers/hdwatched.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { MWProviderContext } from "../helpers/provider";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
|
||||
const hdwatchedBase = "https://www.hdwatched.xyz";
|
||||
|
||||
const qualityMap: Record<number, MWStreamQuality> = {
|
||||
360: MWStreamQuality.Q360P,
|
||||
540: MWStreamQuality.Q540P,
|
||||
480: MWStreamQuality.Q480P,
|
||||
720: MWStreamQuality.Q720P,
|
||||
1080: MWStreamQuality.Q1080P,
|
||||
};
|
||||
|
||||
interface SearchRes {
|
||||
title: string;
|
||||
year?: number;
|
||||
href: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function getStreamFromEmbed(stream: string) {
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch stream");
|
||||
}
|
||||
|
||||
const streamSrc = source.getAttribute("src");
|
||||
const streamRes = source.getAttribute("res");
|
||||
|
||||
if (!streamSrc || !streamRes) throw new Error("Unable to find stream");
|
||||
|
||||
return {
|
||||
streamUrl: streamSrc,
|
||||
quality:
|
||||
streamRes && typeof +streamRes === "number"
|
||||
? qualityMap[+streamRes]
|
||||
: MWStreamQuality.QUNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMovie(targetSource: SearchRes) {
|
||||
const stream = await proxiedFetch<any>(`/embed/${targetSource.id}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch movie stream");
|
||||
}
|
||||
|
||||
return getStreamFromEmbed(stream);
|
||||
}
|
||||
|
||||
async function fetchSeries(
|
||||
targetSource: SearchRes,
|
||||
{ media, episode, progress }: MWProviderContext
|
||||
) {
|
||||
if (media.meta.type !== MWMediaType.SERIES)
|
||||
throw new Error("Media type mismatch");
|
||||
|
||||
const seasonNumber = media.meta.seasonData.number;
|
||||
const episodeNumber = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
if (!seasonNumber || !episodeNumber)
|
||||
throw new Error("Unable to get season or episode number");
|
||||
|
||||
const seriesPage = await proxiedFetch<any>(
|
||||
`${targetSource.href}?season=${media.meta.seasonData.number}`,
|
||||
{
|
||||
baseURL: hdwatchedBase,
|
||||
}
|
||||
);
|
||||
|
||||
const seasonPage = new DOMParser().parseFromString(seriesPage, "text/html");
|
||||
const pageElements = seasonPage.querySelectorAll("div.i-container");
|
||||
|
||||
const seriesList: SearchRes[] = [];
|
||||
pageElements.forEach((pageElement) => {
|
||||
const href = pageElement.querySelector("a")?.getAttribute("href") || "";
|
||||
const title =
|
||||
pageElement?.querySelector("span.content-title")?.textContent || "";
|
||||
|
||||
seriesList.push({
|
||||
title,
|
||||
href,
|
||||
id: href.split("/")[2], // Format: /free/{id}/{series-slug}-season-{season-number}-episode-{episode-number}
|
||||
});
|
||||
});
|
||||
|
||||
const targetEpisode = seriesList.find(
|
||||
(episodeEl) =>
|
||||
episodeEl.title.trim().toLowerCase() === `episode ${episodeNumber}`
|
||||
);
|
||||
|
||||
if (!targetEpisode) throw new Error("Unable to find episode");
|
||||
|
||||
progress(70);
|
||||
|
||||
const stream = await proxiedFetch<any>(`/embed/${targetEpisode.id}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch movie stream");
|
||||
}
|
||||
|
||||
return getStreamFromEmbed(stream);
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "hdwatched",
|
||||
displayName: "HDwatched",
|
||||
rank: 150,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
async scrape(options) {
|
||||
const { media, progress } = options;
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
|
||||
const search = await proxiedFetch<any>(`/search/${media.imdbId}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const searchPage = new DOMParser().parseFromString(search, "text/html");
|
||||
const pageElements = searchPage.querySelectorAll("div.i-container");
|
||||
|
||||
const searchList: SearchRes[] = [];
|
||||
pageElements.forEach((pageElement) => {
|
||||
const href = pageElement.querySelector("a")?.getAttribute("href") || "";
|
||||
const title =
|
||||
pageElement?.querySelector("span.content-title")?.textContent || "";
|
||||
const year =
|
||||
parseInt(
|
||||
pageElement
|
||||
?.querySelector("div.duration")
|
||||
?.textContent?.trim()
|
||||
?.split(" ")
|
||||
?.pop() || "",
|
||||
10
|
||||
) || 0;
|
||||
|
||||
searchList.push({
|
||||
title,
|
||||
year,
|
||||
href,
|
||||
id: href.split("/")[2], // Format: /free/{id}/{movie-slug} or /series/{id}/{series-slug}
|
||||
});
|
||||
});
|
||||
|
||||
progress(20);
|
||||
|
||||
const targetSource = searchList.find(
|
||||
(source) => source.year === (media.meta.year ? +media.meta.year : 0) // Compare year to make the search more robust
|
||||
);
|
||||
|
||||
if (!targetSource) {
|
||||
throw new Error("Could not find stream");
|
||||
}
|
||||
|
||||
progress(40);
|
||||
|
||||
if (media.meta.type === MWMediaType.SERIES) {
|
||||
const series = await fetchSeries(targetSource, options);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: series.streamUrl,
|
||||
quality: series.quality,
|
||||
type: MWStreamType.MP4,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const movie = await fetchMovie(targetSource);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: movie.streamUrl,
|
||||
quality: movie.quality,
|
||||
type: MWStreamType.MP4,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
29
src/components/CaptionColorSelector.tsx
Normal file
29
src/components/CaptionColorSelector.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useSettings } from "@/state/settings";
|
||||
import { Icon, Icons } from "./Icon";
|
||||
|
||||
export const colors = ["#ffffff", "#00ffff", "#ffff00"];
|
||||
export default function CaptionColorSelector({ color }: { color: string }) {
|
||||
const { captionSettings, setCaptionColor } = useSettings();
|
||||
return (
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded transition-[background-color,transform] duration-100 hover:bg-[#1c161b79] active:scale-110 ${
|
||||
color === captionSettings.style.color ? "bg-[#1C161B]" : ""
|
||||
}`}
|
||||
onClick={() => setCaptionColor(color)}
|
||||
>
|
||||
<div
|
||||
className="h-4 w-4 cursor-pointer appearance-none rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
<Icon
|
||||
className={[
|
||||
"absolute text-xs text-[#1C161B]",
|
||||
color === captionSettings.style.color ? "" : "hidden",
|
||||
].join(" ")}
|
||||
icon={Icons.CHECKMARK}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -37,7 +37,7 @@ export function Dropdown(props: DropdownProps) {
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute bottom-11 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:bottom-10 sm:text-sm">
|
||||
<Listbox.Options className="absolute top-10 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:top-10 sm:text-sm">
|
||||
{props.options.map((opt) => (
|
||||
<Listbox.Option
|
||||
className={({ active }) =>
|
||||
|
@@ -35,9 +35,14 @@ export function Modal(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalCard(props: { children?: ReactNode }) {
|
||||
export function ModalCard(props: { className?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="relative mx-2 max-w-[600px] overflow-hidden rounded-lg bg-denim-200 px-10 py-10">
|
||||
<div
|
||||
className={[
|
||||
"relative mx-2 w-[500px] overflow-hidden rounded-lg bg-denim-300 px-10 py-10 sm:w-[500px] md:w-[500px] lg:w-[1000px]",
|
||||
props.className ?? "",
|
||||
].join(" ")}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { conf } from "@/setup/config";
|
||||
import { useBannerSize } from "@/hooks/useBanner";
|
||||
import SettingsModal from "@/views/SettingsModal";
|
||||
import { BrandPill } from "./BrandPill";
|
||||
|
||||
export interface NavigationProps {
|
||||
@@ -13,7 +14,7 @@ export interface NavigationProps {
|
||||
|
||||
export function Navigation(props: NavigationProps) {
|
||||
const bannerHeight = useBannerSize();
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className="fixed left-0 right-0 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent"
|
||||
@@ -42,6 +43,14 @@ export function Navigation(props: NavigationProps) {
|
||||
props.children ? "hidden sm:flex" : "flex"
|
||||
} relative flex-row gap-4`}
|
||||
>
|
||||
<IconPatch
|
||||
className="text-2xl text-white"
|
||||
icon={Icons.GEAR}
|
||||
clickable
|
||||
onClick={() => {
|
||||
setShowModal(true);
|
||||
}}
|
||||
/>
|
||||
<a
|
||||
href={conf().DISCORD_LINK}
|
||||
target="_blank"
|
||||
@@ -60,6 +69,7 @@ export function Navigation(props: NavigationProps) {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsModal show={showModal} onClose={() => setShowModal(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition";
|
||||
import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
@@ -133,13 +134,15 @@ export const FloatingCardView = {
|
||||
action?: React.ReactNode;
|
||||
backText?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
let left = (
|
||||
<div
|
||||
onClick={props.goBack}
|
||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
||||
>
|
||||
<Icon icon={Icons.ARROW_LEFT} />
|
||||
<span>{props.backText || "Go back"}</span>
|
||||
<span>{props.backText || t("videoPlayer.popouts.back")}</span>
|
||||
</div>
|
||||
);
|
||||
if (props.close)
|
||||
|
@@ -7,12 +7,12 @@ export function useVolumeControl(descriptor: string) {
|
||||
const controls = useControls(descriptor);
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
|
||||
const toggleVolume = () => {
|
||||
const toggleVolume = (isKeyboardEvent = false) => {
|
||||
if (mediaPlaying.volume > 0) {
|
||||
setStoredVolume(mediaPlaying.volume);
|
||||
controls.setVolume(0);
|
||||
controls.setVolume(0, isKeyboardEvent);
|
||||
} else {
|
||||
controls.setVolume(storedVolume > 0 ? storedVolume : 1);
|
||||
controls.setVolume(storedVolume > 0 ? storedVolume : 1, isKeyboardEvent);
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -9,6 +9,7 @@ import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
import App from "@/setup/App";
|
||||
import "@/setup/ga";
|
||||
import "@/setup/sentry";
|
||||
import "@/setup/i18n";
|
||||
import "@/setup/index.css";
|
||||
import "@/backend";
|
||||
|
@@ -2,3 +2,5 @@ export const APP_VERSION = import.meta.env.PACKAGE_VERSION;
|
||||
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
|
||||
export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
|
||||
export const GA_ID = "G-44YVXRL61C";
|
||||
export const SENTRY_DSN =
|
||||
"https://b267ab7d52674c23af4e4e6cf2956251@o4505053491167232.ingest.sentry.io/4505053495296000";
|
||||
|
@@ -4,7 +4,26 @@ import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
// Languages
|
||||
import en from "./locales/en/translation.json";
|
||||
import nl from "./locales/nl/translation.json";
|
||||
import tr from "./locales/tr/translation.json";
|
||||
import fr from "./locales/fr/translation.json";
|
||||
|
||||
import { captionLanguages } from "./iso6391";
|
||||
|
||||
const locales = {
|
||||
en: {
|
||||
translation: en,
|
||||
},
|
||||
nl: {
|
||||
translation: nl,
|
||||
},
|
||||
tr: {
|
||||
translation: tr,
|
||||
},
|
||||
fr: {
|
||||
translation: fr,
|
||||
},
|
||||
};
|
||||
i18n
|
||||
// detect user language
|
||||
// learn more: https://github.com/i18next/i18next-browser-languageDetector
|
||||
@@ -15,16 +34,14 @@ i18n
|
||||
// for all options read: https://www.i18next.com/overview/configuration-options
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
|
||||
resources: {
|
||||
en: {
|
||||
translation: en,
|
||||
},
|
||||
},
|
||||
|
||||
resources: locales,
|
||||
interpolation: {
|
||||
escapeValue: false, // not needed for react as it escapes by default
|
||||
},
|
||||
});
|
||||
|
||||
export const appLanguageOptions = captionLanguages.filter((x) => {
|
||||
return Object.keys(locales).includes(x.id);
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
1326
src/setup/iso6391.ts
Normal file
1326
src/setup/iso6391.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,8 @@
|
||||
"backToHome": "Back to home",
|
||||
"backToHomeShort": "Back",
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
||||
"timeLeft": "{{timeLeft}} left",
|
||||
"finishAt": "Finish at {{timeFinished}}",
|
||||
"buttons": {
|
||||
"episodes": "Episodes",
|
||||
"source": "Source",
|
||||
@@ -67,6 +69,7 @@
|
||||
"playbackSpeed": "Playback speed"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "Go back",
|
||||
"sources": "Sources",
|
||||
"seasons": "Seasons",
|
||||
"captions": "Captions",
|
||||
@@ -104,6 +107,11 @@
|
||||
"fatalError": "The video player encounted a fatal error, please report it to the <0>Discord server</0> or on <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"captionLanguage": "Caption Language"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "New version now released!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
|
@@ -16,16 +16,34 @@
|
||||
"placeholder": "Que voulez-vous voir?"
|
||||
},
|
||||
"media": {
|
||||
"title": "Impossible de trouver ce média",
|
||||
"description": "Nous n'avons pas pu trouver le média que vous avez demandé. Soit il a été supprimé, soit vous avez altéré l'URL."
|
||||
"movie": "Films",
|
||||
"series": "Séries",
|
||||
"stopEditing": "Arrêter l'édition",
|
||||
"errors": {
|
||||
"genericTitle": "Oups, c'est coupé !",
|
||||
"failedMeta": "Impossible de charger les métadonnées",
|
||||
"mediaFailed": "Nous n'avons pas réussi à récupérer le média que vous avez demandé. Veuillez vérifier votre connexion Internet et réessayer.",
|
||||
"videoFailed": "Nous avons rencontré une erreur lors de la lecture de la vidéo que vous avez demandée. Si cela se reproduit, veuillez signaler le problème au serveur <0>Discord</0> ou sur <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"title": "Ce fournisseur a été désactivé",
|
||||
"description": "Nous avons eu des problèmes avec le fournisseur ou bien il était trop instable pour être utilisé, donc nous avons dû le désactiver."
|
||||
"seasons": {
|
||||
"seasonAndEpisode": "S{{saison}} E{{épisode}}"
|
||||
},
|
||||
"page": {
|
||||
"title": "Impossible de trouver cette page",
|
||||
"description": "Nous avons cherché partout : sous les poubelles, dans le placard, derrière le proxy, mais nous n'avons finalement pas pu trouver la page que vous recherchez."
|
||||
"notFound": {
|
||||
"genericTitle": "Introuvable",
|
||||
"backArrow": "Retour à l'accueil",
|
||||
"media": {
|
||||
"title": "Impossible de trouver ce média",
|
||||
"description": "Nous n'avons pas trouvé le média que vous avez demandé. Soit il a été supprimé, soit vous avez modifié l'URL."
|
||||
},
|
||||
"provider": {
|
||||
"title": "Ce fournisseur a été désactivé",
|
||||
"description": "Nous avons eu des problèmes avec le fournisseur ou il était trop instable pour être utilisé, nous avons donc dû le désactiver."
|
||||
},
|
||||
"page": {
|
||||
"title": "Impossible de trouver cette page",
|
||||
"description": "Nous avons cherché partout : sous les poubelles, dans le placard, derrière le proxy, mais nous n'avons finalement pas trouvé la page que vous cherchez."
|
||||
}
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "Film",
|
||||
@@ -39,18 +57,24 @@
|
||||
"backToHome": "Retour à la page d'accueil",
|
||||
"backToHomeShort": "Retour",
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
||||
"timeLeft": "{{timeLeft}} restant",
|
||||
"finishAt": "Terminer à {{timeFinished}}",
|
||||
"buttons": {
|
||||
"episodes": "Épisodes",
|
||||
"source": "Source",
|
||||
"captions": "Sous-titres",
|
||||
"download": "Télécharger",
|
||||
"settings": "Paramètres",
|
||||
"pictureInPicture": "Image dans l'image"
|
||||
"pictureInPicture": "Image dans l'image",
|
||||
"playbackSpeed": "Vitesse"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "Retourner",
|
||||
"sources": "Sources",
|
||||
"seasons": "Saisons",
|
||||
"captions": "Sous-titres",
|
||||
"playbackSpeed": "Vitesse de lecture",
|
||||
"customPlaybackSpeed": "Vitesse de lecture personnalisée",
|
||||
"captionPreferences": {
|
||||
"title": "Personnaliser",
|
||||
"delay": "Délai",
|
||||
@@ -74,13 +98,19 @@
|
||||
"seasons": "Choisissez la saison que vous voulez regarder",
|
||||
"episode": "Sélectionnez un épisode",
|
||||
"captions": "Choisissez une langue de sous-titres",
|
||||
"captionPreferences": "Personnalisez l'apparence des sous-titres"
|
||||
"captionPreferences": "Personnalisez l'apparence des sous-titres",
|
||||
"playbackSpeed": "Changer la vitesse de lecture"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "Le lecteur vidéo a rencontré une erreur fatale, veuillez la signaler au serveur <0>Discord</0> ou sur <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"language": "Language",
|
||||
"captionLanguage": "Langue des sous-titres"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "Nouvelle version disponible!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
|
128
src/setup/locales/nl/translation.json
Normal file
128
src/setup/locales/nl/translation.json
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "We zoeken je favoriete series...",
|
||||
"loading_movie": "We zoeken je favoriete films...",
|
||||
"loading": "Aan het zoeken...",
|
||||
"allResults": "Dat is het!",
|
||||
"noResults": "We konden helaas niets vinden.",
|
||||
"allFailed": "Het is niet gelukt de media te laden, probeer het nog eens.",
|
||||
"headingTitle": "Zoekresultaten",
|
||||
"bookmarks": "Opgeslagen",
|
||||
"continueWatching": "Kijk verder",
|
||||
"title": "Wat wil je graag kijken?",
|
||||
"placeholder": "Wat wil je graag kijken?"
|
||||
},
|
||||
"media": {
|
||||
"movie": "Film",
|
||||
"series": "Serie",
|
||||
"stopEditing": "Stop met bewerken",
|
||||
"errors": {
|
||||
"genericTitle": "Oeps, hier ging iets mis!",
|
||||
"failedMeta": "Het is niet gelukt de meta-informatie op te halen/",
|
||||
"mediaFailed": "Het is niet gelukt deze media op te halen. Controleer of je een internetverbinding hebt en probeer het nog een keer.",
|
||||
"videoFailed": "Er ging iets mis tijdens het spelen van deze video. Als dit blijft gebeuren, deel het dan in de <0>Discord server</0> of maak een <1>GitHub issue</1>."
|
||||
}
|
||||
},
|
||||
"seasons": {
|
||||
"seasonAndEpisode": "S{{season}} A{{episode}}"
|
||||
},
|
||||
"notFound": {
|
||||
"genericTitle": "Pagina niet gevonden",
|
||||
"backArrow": "Naar de home-pagina",
|
||||
"media": {
|
||||
"title": "We konden deze media niet vinden.",
|
||||
"description": "We konden dit stukje media niet vinden. Het is mogelijk verwijderd, of jij hebt zelf de URL aangepast."
|
||||
},
|
||||
"provider": {
|
||||
"title": "Deze bron is niet langer beschikbaar",
|
||||
"description": "Deze bron was helaas te instabiel, we hebben hem jammer genoeg uit moeten zetten."
|
||||
},
|
||||
"page": {
|
||||
"title": "Pagina niet gevonden",
|
||||
"description": "We hebben echt alles geprobeerd, zelfs tijdrijzen; echter hebben we deze pagina helaas niet kunnen vinden."
|
||||
}
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "Films",
|
||||
"series": "Series",
|
||||
"Search": "Zoeken"
|
||||
},
|
||||
"videoPlayer": {
|
||||
"findingBestVideo": "De beste video voor jou aan het zoeken...",
|
||||
"noVideos": "Helaas konden we dat filmpje niet vinden",
|
||||
"loading": "Aan het laden...",
|
||||
"backToHome": "Naar de home-pagina",
|
||||
"backToHomeShort": "Terug",
|
||||
"seasonAndEpisode": "S{{season}} A{{episode}}",
|
||||
"timeLeft": "Nog {{timeLeft}}",
|
||||
"finishAt": "Afgelopen om {{timeFinished}}",
|
||||
"buttons": {
|
||||
"episodes": "Afleveringen",
|
||||
"source": "Bron",
|
||||
"captions": "Ondertiteling",
|
||||
"download": "Download",
|
||||
"settings": "Instellingen",
|
||||
"pictureInPicture": "Beeld-in-beeld",
|
||||
"playbackSpeed": "Afspeelsnelheid"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "Terug",
|
||||
"sources": "Bronnen",
|
||||
"seasons": "Seizoenen",
|
||||
"captions": "Ondertiteling",
|
||||
"playbackSpeed": "Afspeelsnelheid",
|
||||
"customPlaybackSpeed": "Andere snelheden",
|
||||
"captionPreferences": {
|
||||
"title": "Instellingen",
|
||||
"delay": "Vertraging",
|
||||
"fontSize": "Lettergrootte",
|
||||
"opacity": "Doorzichtbaarheid",
|
||||
"color": "Kleur"
|
||||
},
|
||||
"episode": "A{{index}} - {{title}}",
|
||||
"noCaptions": "Geen ondertiteling",
|
||||
"linkedCaptions": "Gelinkte ondertiteling",
|
||||
"customCaption": "Eigen ondertiteling",
|
||||
"uploadCustomCaption": "Ondertiteling uploaden",
|
||||
"noEmbeds": "We hebben geen filmpjes kunnen vinden voor deze bron.",
|
||||
|
||||
"errors": {
|
||||
"loadingWentWong": "Er ging iets mis tijdens het laden van de afleveringen voor {{seasonTitle}}",
|
||||
"embedsError": "Er ging iets mis tijdens het laden van de embeds voor dit dingetje dat je waarschijnlijk leuk vindt"
|
||||
},
|
||||
"descriptions": {
|
||||
"sources": "Welke bron wil je graag gebruiken",
|
||||
"embeds": "Welk filmpje wil je gebruiken?",
|
||||
"seasons": "Welk seizoen wil je kijken?",
|
||||
"episode": "Kies een aflevering",
|
||||
"captions": "Kies een taal voor de ondertiteling",
|
||||
"captionPreferences": "Pas de ondertiteling aan aan je voorkeuren",
|
||||
"playbackSpeed": "Pas de afspeelsnelhijd aan"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "De videospeler is helaas ontploft, rapporteer deze fout op de <0>Discord server</0> of op <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Instellingen",
|
||||
"language": "Taal",
|
||||
"captionLanguage": "Taal voor de Ondertiteling"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "De nieuwe versie is uit!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
"newDomainText": "We gaan binnenkort verhuizen naar een nieuw domein: <0>https://movie-web.app</0>. Pas je bladwijzers aan naar het nieuwe domein, want </b>het oude domein gaat stoppen met werken op {{date}}.</b>",
|
||||
"tireless": "We hebben mega hard gewerkt aan deze nieuwe versie, dus we hopen dat je er van gaat genieten.",
|
||||
"leaveAnnouncement": "Let's go!"
|
||||
},
|
||||
"casting": {
|
||||
"casting": "Aan het casten..."
|
||||
},
|
||||
"errors": {
|
||||
"offline": "Controleer je internetverbinding"
|
||||
}
|
||||
}
|
128
src/setup/locales/tr/translation.json
Normal file
128
src/setup/locales/tr/translation.json
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Favori dizileriniz aranıyor...",
|
||||
"loading_movie": "Favori filmleriniz aranıyor...",
|
||||
"loading": "Yükleniyor...",
|
||||
"allResults": "Bu kadarını bulabildik!",
|
||||
"noResults": "Hiçbir şey bulamadık!",
|
||||
"allFailed": "Medya bulunamadı, tekrar deneyin!",
|
||||
"headingTitle": "Arama sonuçları",
|
||||
"bookmarks": "Yerimleri",
|
||||
"continueWatching": "İzlemeye devam edin",
|
||||
"title": "Ne izlemek istersiniz?",
|
||||
"placeholder": "Ne izlemek istersiniz?"
|
||||
},
|
||||
"media": {
|
||||
"movie": "Film",
|
||||
"series": "Dizi",
|
||||
"stopEditing": "Düzenlemeyi durdur",
|
||||
"errors": {
|
||||
"genericTitle": "Hay aksi, bozuldu!",
|
||||
"failedMeta": "Önbilgi yüklenemedi",
|
||||
"mediaFailed": "İstediğiniz medyaya istek atarken hata oluştu, internet bağlantınızı kontrol edin ve tekrar deneyin.",
|
||||
"videoFailed": "İstediğiniz videoyu oynatırken bir sorunla karşılaştık. Bu durum devam ederse lütfen bunu <0>Discord sunucumuza</0> veya <1>GitHub</1> üzerinden bildiriniz."
|
||||
}
|
||||
},
|
||||
"seasons": {
|
||||
"seasonAndEpisode": "S{{season}} B{{episode}}"
|
||||
},
|
||||
"notFound": {
|
||||
"genericTitle": "Bulunamadı",
|
||||
"backArrow": "Geri",
|
||||
"media": {
|
||||
"title": "Medya bulunamadı",
|
||||
"description": "İstediğiniz medyayı bulamadık. URL'i yanlış girdiniz ya da medya kaldırıldı."
|
||||
},
|
||||
"provider": {
|
||||
"title": "Bu sağlayıcı devre dışı bırakıldı",
|
||||
"description": "Sağlayıcı ile ilgili bir sorun oluştu ya da kullanılacak kadar stabil değildi bu yüzden devre dışı bırakmak zorunda kaldık."
|
||||
},
|
||||
"page": {
|
||||
"title": "Sayfa bulunamadı",
|
||||
"description": "Her yere baktık: bazanın altına, dolabın içine hatta ara sunucuya ama maalesef aradığınız sayfayı bulamadık."
|
||||
}
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "Film",
|
||||
"series": "Dizi",
|
||||
"Search": "Ara"
|
||||
},
|
||||
"videoPlayer": {
|
||||
"findingBestVideo": "Sizin için en iyi videoyu buluyoruz...",
|
||||
"noVideos": "Hay aksi, hiçbir video bulamadık",
|
||||
"loading": "Yükleniyor...",
|
||||
"backToHome": "Ana sayfaya dön",
|
||||
"backToHomeShort": "Geri",
|
||||
"seasonAndEpisode": "S{{season}} B{{episode}}",
|
||||
"timeLeft": "{{timeLeft}} kaldı",
|
||||
"finishAt": "{{timeFinished, datetime}}'de/da bitiyor",
|
||||
"buttons": {
|
||||
"episodes": "Bölümler",
|
||||
"source": "Kaynak",
|
||||
"captions": "Altyazılar",
|
||||
"download": "İndir",
|
||||
"settings": "Ayarlar",
|
||||
"pictureInPicture": "Resim içinde Resim",
|
||||
"playbackSpeed": "Oynatma Hızı"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "Geri git",
|
||||
"sources": "Kaynaklar",
|
||||
"seasons": "Sezonlar",
|
||||
"captions": "Altyazılar",
|
||||
"playbackSpeed": "Oynatma hızı",
|
||||
"customPlaybackSpeed": "Özel oynatma hızı",
|
||||
"captionPreferences": {
|
||||
"title": "Kişiselleştirme",
|
||||
"delay": "Gecikme",
|
||||
"fontSize": "Boyut",
|
||||
"opacity": "Opaklık",
|
||||
"color": "Renk"
|
||||
},
|
||||
"episode": "B{{index}} - {{title}}",
|
||||
"noCaptions": "Altyazı yok",
|
||||
"linkedCaptions": "Kaynak Altyazıları",
|
||||
"customCaption": "Özel altyazı",
|
||||
"uploadCustomCaption": "Altyazı yükle",
|
||||
"noEmbeds": "Bu kaynak için gömülü video bulunamadı",
|
||||
|
||||
"errors": {
|
||||
"loadingWentWong": "{{seasonTitle}} için bölümler yüklenirken bir hata oluştu",
|
||||
"embedsError": "İstediğiniz şey için gömülü video bulunurken bir hata oluştu"
|
||||
},
|
||||
"descriptions": {
|
||||
"sources": "Hangi sağlayıcıyı kullanmak istersiniz?",
|
||||
"embeds": "Görüntülemek istediğiniz videoyu seçiniz",
|
||||
"seasons": "İzlemek istediğiniz sezonu seçiniz",
|
||||
"episode": "Bir bölüm seçiniz",
|
||||
"captions": "Altyazı dili seçiniz",
|
||||
"captionPreferences": "Altyazıları istediğiniz gibi ayarlayın",
|
||||
"playbackSpeed": "Oynatma hızınızı değiştirin"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "Video oynatıcıda bir hata oluştu, lütfen bunu <0>Discord sunucumuzda</0> ya da <1>GitHub</1> üzeriden bildiriniz."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ayarlar",
|
||||
"language": "Dil",
|
||||
"captionLanguage": "Altyazı Dili"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "Yeni sürüm yayınlandı!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
"newDomainText": "movie-web yakında yeni bir alan adına taşınacak: <0>https://movie-web.app</0>. <1>{{date}} tarihinde eski site çalışmayacağı için</1> yerimlerinizi güncellemeyi unutmayın.",
|
||||
"tireless": "Bu yeni güncelleme için gece gündüz çalıştık, umarız aylardan beri hazırladığımız bu güncellemeyi beğenirsiniz.",
|
||||
"leaveAnnouncement": "Götür beni!"
|
||||
},
|
||||
"casting": {
|
||||
"casting": "Cihaza aktarılıyor..."
|
||||
},
|
||||
"errors": {
|
||||
"offline": "İnternet bağlantınızı kontrol ediniz"
|
||||
}
|
||||
}
|
15
src/setup/sentry.tsx
Normal file
15
src/setup/sentry.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { CaptureConsole, HttpClient } from "@sentry/integrations";
|
||||
import { SENTRY_DSN } from "@/setup/constants";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
release: `movie-web@${conf().APP_VERSION}`,
|
||||
sampleRate: 0.5,
|
||||
integrations: [
|
||||
new Sentry.BrowserTracing(),
|
||||
new CaptureConsole(),
|
||||
new HttpClient(),
|
||||
],
|
||||
});
|
@@ -1,14 +1,16 @@
|
||||
import { useStore } from "@/utils/storage";
|
||||
import { createContext, ReactNode, useContext, useMemo } from "react";
|
||||
import { LangCode } from "@/setup/iso6391";
|
||||
import { SettingsStore } from "./store";
|
||||
import { MWSettingsData } from "./types";
|
||||
|
||||
interface MWSettingsDataSetters {
|
||||
setLanguage(language: string): void;
|
||||
setLanguage(language: LangCode): void;
|
||||
setCaptionLanguage(language: LangCode): void;
|
||||
setCaptionDelay(delay: number): void;
|
||||
setCaptionColor(color: string): void;
|
||||
setCaptionFontSize(size: number): void;
|
||||
setCaptionBackgroundColor(backgroundColor: string): void;
|
||||
setCaptionBackgroundColor(backgroundColor: number): void;
|
||||
}
|
||||
type MWSettingsDataWrapper = MWSettingsData & MWSettingsDataSetters;
|
||||
const SettingsContext = createContext<MWSettingsDataWrapper>(null as any);
|
||||
@@ -17,7 +19,6 @@ export function SettingsProvider(props: { children: ReactNode }) {
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
const [settings, setSettings] = useStore(SettingsStore);
|
||||
|
||||
const context: MWSettingsDataWrapper = useMemo(() => {
|
||||
const settingsContext: MWSettingsDataWrapper = {
|
||||
...settings,
|
||||
@@ -29,6 +30,14 @@ export function SettingsProvider(props: { children: ReactNode }) {
|
||||
};
|
||||
});
|
||||
},
|
||||
setCaptionLanguage(language) {
|
||||
setSettings((oldSettings) => {
|
||||
const captionSettings = oldSettings.captionSettings;
|
||||
captionSettings.language = language;
|
||||
const newSettings = oldSettings;
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setCaptionDelay(delay: number) {
|
||||
setSettings((oldSettings) => {
|
||||
const captionSettings = oldSettings.captionSettings;
|
||||
@@ -56,7 +65,10 @@ export function SettingsProvider(props: { children: ReactNode }) {
|
||||
setCaptionBackgroundColor(backgroundColor) {
|
||||
setSettings((oldSettings) => {
|
||||
const style = oldSettings.captionSettings.style;
|
||||
style.backgroundColor = backgroundColor;
|
||||
style.backgroundColor = `${style.backgroundColor.substring(
|
||||
0,
|
||||
7
|
||||
)}${backgroundColor.toString(16).padStart(2, "0")}`;
|
||||
const newSettings = oldSettings;
|
||||
return newSettings;
|
||||
});
|
||||
|
@@ -1,11 +1,11 @@
|
||||
import { createVersionedStore } from "@/utils/storage";
|
||||
import { MWSettingsData } from "./types";
|
||||
import { MWSettingsData, MWSettingsDataV1 } from "./types";
|
||||
|
||||
export const SettingsStore = createVersionedStore<MWSettingsData>()
|
||||
.setKey("mw-settings")
|
||||
.addVersion({
|
||||
version: 0,
|
||||
create(): MWSettingsData {
|
||||
create(): MWSettingsDataV1 {
|
||||
return {
|
||||
language: "en",
|
||||
captionSettings: {
|
||||
@@ -18,5 +18,31 @@ export const SettingsStore = createVersionedStore<MWSettingsData>()
|
||||
},
|
||||
};
|
||||
},
|
||||
migrate(data: MWSettingsDataV1): MWSettingsData {
|
||||
return {
|
||||
language: data.language,
|
||||
captionSettings: {
|
||||
language: "none",
|
||||
...data.captionSettings,
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
.addVersion({
|
||||
version: 1,
|
||||
create(): MWSettingsData {
|
||||
return {
|
||||
language: "en",
|
||||
captionSettings: {
|
||||
delay: 0,
|
||||
language: "none",
|
||||
style: {
|
||||
color: "#ffffff",
|
||||
fontSize: 25,
|
||||
backgroundColor: "#00000096",
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
.build();
|
||||
|
@@ -1,3 +1,5 @@
|
||||
import { LangCode } from "@/setup/iso6391";
|
||||
|
||||
export interface CaptionStyleSettings {
|
||||
color: string;
|
||||
/**
|
||||
@@ -7,7 +9,7 @@ export interface CaptionStyleSettings {
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
export interface CaptionSettings {
|
||||
export interface CaptionSettingsV1 {
|
||||
/**
|
||||
* Range is [-10, 10]s
|
||||
*/
|
||||
@@ -15,7 +17,20 @@ export interface CaptionSettings {
|
||||
style: CaptionStyleSettings;
|
||||
}
|
||||
|
||||
export interface CaptionSettings {
|
||||
language: LangCode;
|
||||
/**
|
||||
* Range is [-10, 10]s
|
||||
*/
|
||||
delay: number;
|
||||
style: CaptionStyleSettings;
|
||||
}
|
||||
export interface MWSettingsDataV1 {
|
||||
language: LangCode;
|
||||
captionSettings: CaptionSettingsV1;
|
||||
}
|
||||
|
||||
export interface MWSettingsData {
|
||||
language: string;
|
||||
language: LangCode;
|
||||
captionSettings: CaptionSettings;
|
||||
}
|
||||
|
@@ -31,6 +31,7 @@ import { PictureInPictureAction } from "@/video/components/actions/PictureInPict
|
||||
import { CaptionRendererAction } from "./actions/CaptionRendererAction";
|
||||
import { SettingsAction } from "./actions/SettingsAction";
|
||||
import { DividerAction } from "./actions/DividerAction";
|
||||
import { VolumeAdjustedAction } from "./actions/VolumeAdjustedAction";
|
||||
|
||||
type Props = VideoPlayerBaseProps;
|
||||
|
||||
@@ -91,6 +92,7 @@ export function VideoPlayer(props: Props) {
|
||||
<>
|
||||
<KeyboardShortcutsAction />
|
||||
<PageTitleAction />
|
||||
<VolumeAdjustedAction />
|
||||
<VideoPlayerError onGoBack={props.onGoBack}>
|
||||
<BackdropAction onBackdropChange={onBackdropChange}>
|
||||
<CenterPosition>
|
||||
|
@@ -24,18 +24,16 @@ export function BackdropAction(props: BackdropActionProps) {
|
||||
const handleMouseMove = useCallback(() => {
|
||||
if (!moved) {
|
||||
setTimeout(() => {
|
||||
// If NOT a touch, set moved to true
|
||||
const isTouch = Date.now() - lastTouchEnd.current < 200;
|
||||
if (!isTouch) {
|
||||
setMoved(true);
|
||||
}
|
||||
if (!isTouch) setMoved(true);
|
||||
}, 20);
|
||||
return;
|
||||
}
|
||||
|
||||
// remove after all
|
||||
if (timeout.current) clearTimeout(timeout.current);
|
||||
timeout.current = setTimeout(() => {
|
||||
if (moved) setMoved(false);
|
||||
setMoved(false);
|
||||
timeout.current = null;
|
||||
}, 3000);
|
||||
}, [setMoved, moved]);
|
||||
|
@@ -8,7 +8,7 @@ import { useVideoPlayerDescriptor } from "../../state/hooks";
|
||||
import { useProgress } from "../../state/logic/progress";
|
||||
import { useSource } from "../../state/logic/source";
|
||||
|
||||
function CaptionCue({ text }: { text?: string }) {
|
||||
export function CaptionCue({ text, scale }: { text?: string; scale?: number }) {
|
||||
const { captionSettings } = useSettings();
|
||||
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />");
|
||||
|
||||
@@ -25,6 +25,7 @@ function CaptionCue({ text }: { text?: string }) {
|
||||
className="pointer-events-none mb-1 select-none rounded px-4 py-1 text-center [text-shadow:0_2px_4px_rgba(0,0,0,0.5)]"
|
||||
style={{
|
||||
...captionSettings.style,
|
||||
fontSize: captionSettings.style.fontSize * (scale ?? 1),
|
||||
}}
|
||||
>
|
||||
<span
|
||||
|
@@ -60,17 +60,17 @@ export function KeyboardShortcutsAction() {
|
||||
|
||||
// Mute
|
||||
case "m":
|
||||
toggleVolume();
|
||||
toggleVolume(true);
|
||||
break;
|
||||
|
||||
// Decrease volume
|
||||
case "arrowdown":
|
||||
controls.setVolume(Math.max(mediaPlaying.volume - 0.1, 0));
|
||||
controls.setVolume(Math.max(mediaPlaying.volume - 0.1, 0), true);
|
||||
break;
|
||||
|
||||
// Increase volume
|
||||
case "arrowup":
|
||||
controls.setVolume(Math.min(mediaPlaying.volume + 0.1, 1));
|
||||
controls.setVolume(Math.min(mediaPlaying.volume + 0.1, 1), true);
|
||||
break;
|
||||
|
||||
// Do a barrel Roll!
|
||||
|
@@ -1,6 +1,11 @@
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||
import { useProgress } from "@/video/state/logic/progress";
|
||||
import { useInterface } from "@/video/state/logic/interface";
|
||||
import { VideoPlayerTimeFormat } from "@/video/state/types";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
|
||||
function durationExceedsHour(secs: number): boolean {
|
||||
return secs > 60 * 60;
|
||||
@@ -37,19 +42,71 @@ export function TimeAction(props: Props) {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const videoTime = useProgress(descriptor);
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
const { setTimeFormat } = useControls(descriptor);
|
||||
const { timeFormat } = useInterface(descriptor);
|
||||
const { isMobile } = useIsMobile();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasHours = durationExceedsHour(videoTime.duration);
|
||||
const time = formatSeconds(
|
||||
|
||||
const currentTime = formatSeconds(
|
||||
mediaPlaying.isDragSeeking ? videoTime.draggingTime : videoTime.time,
|
||||
hasHours
|
||||
);
|
||||
const duration = formatSeconds(videoTime.duration, hasHours);
|
||||
const timeLeft = formatSeconds(
|
||||
(videoTime.duration - videoTime.time) / mediaPlaying.playbackSpeed,
|
||||
hasHours
|
||||
);
|
||||
const timeFinished = new Date(
|
||||
new Date().getTime() +
|
||||
(videoTime.duration * 1000) / mediaPlaying.playbackSpeed
|
||||
).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
const formattedTimeFinished = ` - ${t("videoPlayer.finishAt", {
|
||||
timeFinished,
|
||||
})}`;
|
||||
|
||||
let formattedTime: string;
|
||||
|
||||
if (timeFormat === VideoPlayerTimeFormat.REGULAR) {
|
||||
formattedTime = `${currentTime} ${props.noDuration ? "" : `/ ${duration}`}`;
|
||||
} else if (timeFormat === VideoPlayerTimeFormat.REMAINING && !isMobile) {
|
||||
formattedTime = `${t("videoPlayer.timeLeft", {
|
||||
timeLeft,
|
||||
})}${videoTime.time === videoTime.duration ? "" : formattedTimeFinished} `;
|
||||
} else if (timeFormat === VideoPlayerTimeFormat.REMAINING && isMobile) {
|
||||
formattedTime = `-${timeLeft}`;
|
||||
} else {
|
||||
formattedTime = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<p className="select-none text-white">
|
||||
{time} {props.noDuration ? "" : `/ ${duration}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
"group pointer-events-auto text-white transition-transform duration-100 active:scale-110",
|
||||
].join(" ")}
|
||||
onClick={() => {
|
||||
setTimeFormat(
|
||||
timeFormat === VideoPlayerTimeFormat.REGULAR
|
||||
? VideoPlayerTimeFormat.REMAINING
|
||||
: VideoPlayerTimeFormat.REGULAR
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
"flex items-center justify-center rounded-full bg-denim-600 bg-opacity-0 p-2 transition-colors duration-100 group-hover:bg-opacity-50 group-active:bg-denim-500 group-active:bg-opacity-100 sm:px-4",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className={props.className}>
|
||||
<p className="select-none text-white">{formattedTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
32
src/video/components/actions/VolumeAdjustedAction.tsx
Normal file
32
src/video/components/actions/VolumeAdjustedAction.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useInterface } from "@/video/state/logic/interface";
|
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||
|
||||
export function VolumeAdjustedAction() {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const videoInterface = useInterface(descriptor);
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
videoInterface.volumeChangedWithKeybind
|
||||
? "mt-10 scale-100 opacity-100"
|
||||
: "mt-5 scale-75 opacity-0",
|
||||
"absolute left-1/2 z-[100] flex -translate-x-1/2 items-center space-x-4 rounded-full bg-bink-300 bg-opacity-50 py-2 px-5 transition-all duration-100",
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon
|
||||
icon={mediaPlaying.volume > 0 ? Icons.VOLUME : Icons.VOLUME_X}
|
||||
className="text-xl text-white"
|
||||
/>
|
||||
<div className="h-2 w-44 overflow-hidden rounded-full bg-denim-100">
|
||||
<div
|
||||
className="h-full rounded-r-full bg-bink-500 transition-[width] duration-100"
|
||||
style={{ width: `${mediaPlaying.volume * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,4 +1,11 @@
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||
import { getCaptionUrl, makeCaptionId } from "@/backend/helpers/captions";
|
||||
import {
|
||||
MWCaption,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "@/backend/helpers/streams";
|
||||
import { captionLanguages } from "@/setup/iso6391";
|
||||
import { useSettings } from "@/state/settings";
|
||||
import { useInitialized } from "@/video/components/hooks/useInitialized";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
@@ -10,6 +17,19 @@ interface SourceControllerProps {
|
||||
quality: MWStreamQuality;
|
||||
providerId?: string;
|
||||
embedId?: string;
|
||||
captions: MWCaption[];
|
||||
}
|
||||
async function tryFetch(captions: MWCaption[]) {
|
||||
for (let i = 0; i < captions.length; i += 1) {
|
||||
const caption = captions[i];
|
||||
try {
|
||||
const blobUrl = await getCaptionUrl(caption);
|
||||
return { caption, blobUrl };
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SourceController(props: SourceControllerProps) {
|
||||
@@ -17,13 +37,35 @@ export function SourceController(props: SourceControllerProps) {
|
||||
const controls = useControls(descriptor);
|
||||
const { initialized } = useInitialized(descriptor);
|
||||
const didInitialize = useRef<boolean>(false);
|
||||
|
||||
const { captionSettings } = useSettings();
|
||||
useEffect(() => {
|
||||
if (didInitialize.current) return;
|
||||
if (!initialized) return;
|
||||
controls.setSource(props);
|
||||
// get preferred language
|
||||
const preferredLanguage = captionLanguages.find(
|
||||
(v) => v.id === captionSettings.language
|
||||
);
|
||||
if (!preferredLanguage) return;
|
||||
const captions = props.captions.filter(
|
||||
(v) =>
|
||||
// langIso may contain the English name or the native name of the language
|
||||
v.langIso.indexOf(preferredLanguage.englishName) !== -1 ||
|
||||
v.langIso.indexOf(preferredLanguage.nativeName) !== -1
|
||||
);
|
||||
if (!captions) return;
|
||||
// caption url can return a response other than 200
|
||||
// that's why we fetch until we get a 200 response
|
||||
tryFetch(captions).then((response) => {
|
||||
// none of them were successful
|
||||
if (!response) return;
|
||||
// set the preferred language
|
||||
const id = makeCaptionId(response.caption, true);
|
||||
controls.setCaption(id, response.blobUrl);
|
||||
});
|
||||
|
||||
didInitialize.current = true;
|
||||
}, [props, controls, initialized]);
|
||||
}, [props, controls, initialized, captionSettings.language]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
customCaption,
|
||||
getCaptionUrl,
|
||||
makeCaptionId,
|
||||
parseSubtitles,
|
||||
subtitleTypeList,
|
||||
} from "@/backend/helpers/captions";
|
||||
@@ -17,11 +19,6 @@ import { useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
|
||||
const customCaption = "external-custom";
|
||||
function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
|
||||
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
|
||||
}
|
||||
|
||||
export function CaptionSelectionPopout(props: {
|
||||
router: ReturnType<typeof useFloatingRouter>;
|
||||
prefix: string;
|
||||
|
@@ -4,8 +4,10 @@ import { useFloatingRouter } from "@/hooks/useFloatingRouter";
|
||||
import { useSettings } from "@/state/settings";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Slider } from "@/components/Slider";
|
||||
import CaptionColorSelector, {
|
||||
colors,
|
||||
} from "@/components/CaptionColorSelector";
|
||||
|
||||
export function CaptionSettingsPopout(props: {
|
||||
router: ReturnType<typeof useFloatingRouter>;
|
||||
@@ -16,11 +18,9 @@ export function CaptionSettingsPopout(props: {
|
||||
const {
|
||||
captionSettings,
|
||||
setCaptionBackgroundColor,
|
||||
setCaptionColor,
|
||||
setCaptionDelay,
|
||||
setCaptionFontSize,
|
||||
} = useSettings();
|
||||
const colors = ["#ffffff", "#00ffff", "#ffff00"];
|
||||
return (
|
||||
<FloatingView {...props.router.pageProps(props.prefix)} width={375}>
|
||||
<FloatingCardView.Header
|
||||
@@ -39,7 +39,7 @@ export function CaptionSettingsPopout(props: {
|
||||
onChange={(e) => setCaptionDelay(e.target.valueAsNumber)}
|
||||
/>
|
||||
<Slider
|
||||
label="Size"
|
||||
label={t("videoPlayer.popouts.captionPreferences.fontSize") as string}
|
||||
min={14}
|
||||
step={1}
|
||||
max={60}
|
||||
@@ -63,14 +63,7 @@ export function CaptionSettingsPopout(props: {
|
||||
captionSettings.style.backgroundColor.substring(7, 9),
|
||||
16
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setCaptionBackgroundColor(
|
||||
`${captionSettings.style.backgroundColor.substring(
|
||||
0,
|
||||
7
|
||||
)}${e.target.valueAsNumber.toString(16)}`
|
||||
)
|
||||
}
|
||||
onChange={(e) => setCaptionBackgroundColor(e.target.valueAsNumber)}
|
||||
/>
|
||||
<div className="flex flex-row justify-between">
|
||||
<label className="font-bold" htmlFor="color">
|
||||
@@ -78,26 +71,7 @@ export function CaptionSettingsPopout(props: {
|
||||
</label>
|
||||
<div className="flex flex-row gap-2">
|
||||
{colors.map((color) => (
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded transition-[background-color,transform] duration-100 hover:bg-[#1c161b79] active:scale-110 ${
|
||||
color === captionSettings.style.color ? "bg-[#1C161B]" : ""
|
||||
}`}
|
||||
onClick={() => setCaptionColor(color)}
|
||||
>
|
||||
<div
|
||||
className="h-4 w-4 cursor-pointer appearance-none rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
<Icon
|
||||
className={[
|
||||
"absolute text-xs text-[#1C161B]",
|
||||
color === captionSettings.style.color ? "" : "hidden",
|
||||
].join(" ")}
|
||||
icon={Icons.CHECKMARK}
|
||||
/>
|
||||
</div>
|
||||
<CaptionColorSelector color={color} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -32,6 +32,9 @@ function initPlayer(): VideoPlayerState {
|
||||
isFocused: false,
|
||||
leftControlHovering: false,
|
||||
popoutBounds: null,
|
||||
volumeChangedWithKeybind: false,
|
||||
volumeChangedWithKeybindDebounce: null,
|
||||
timeFormat: 0,
|
||||
},
|
||||
|
||||
mediaPlaying: {
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { updateInterface } from "@/video/state/logic/interface";
|
||||
import { updateMeta } from "@/video/state/logic/meta";
|
||||
import { updateProgress } from "@/video/state/logic/progress";
|
||||
import { VideoPlayerMeta } from "@/video/state/types";
|
||||
import { VideoPlayerMeta, VideoPlayerTimeFormat } from "@/video/state/types";
|
||||
import { getPlayerState } from "../cache";
|
||||
import { VideoPlayerStateController } from "../providers/providerTypes";
|
||||
|
||||
@@ -15,6 +15,7 @@ export type ControlMethods = {
|
||||
setDraggingTime(num: number): void;
|
||||
togglePictureInPicture(): void;
|
||||
setPlaybackSpeed(num: number): void;
|
||||
setTimeFormat(num: VideoPlayerTimeFormat): void;
|
||||
};
|
||||
|
||||
export function useControls(
|
||||
@@ -48,8 +49,20 @@ export function useControls(
|
||||
enterFullscreen() {
|
||||
state.stateProvider?.enterFullscreen();
|
||||
},
|
||||
setVolume(volume) {
|
||||
state.stateProvider?.setVolume(volume);
|
||||
setVolume(volume, isKeyboardEvent = false) {
|
||||
if (isKeyboardEvent) {
|
||||
if (state.interface.volumeChangedWithKeybindDebounce)
|
||||
clearTimeout(state.interface.volumeChangedWithKeybindDebounce);
|
||||
|
||||
state.interface.volumeChangedWithKeybind = true;
|
||||
updateInterface(descriptor, state);
|
||||
|
||||
state.interface.volumeChangedWithKeybindDebounce = setTimeout(() => {
|
||||
state.interface.volumeChangedWithKeybind = false;
|
||||
updateInterface(descriptor, state);
|
||||
}, 3e3);
|
||||
}
|
||||
state.stateProvider?.setVolume(volume, isKeyboardEvent);
|
||||
},
|
||||
startAirplay() {
|
||||
state.stateProvider?.startAirplay();
|
||||
@@ -110,5 +123,9 @@ export function useControls(
|
||||
state.stateProvider?.setPlaybackSpeed(num);
|
||||
updateInterface(descriptor, state);
|
||||
},
|
||||
setTimeFormat(format) {
|
||||
state.interface.timeFormat = format;
|
||||
updateInterface(descriptor, state);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getPlayerState } from "../cache";
|
||||
import { listenEvent, sendEvent, unlistenEvent } from "../events";
|
||||
import { VideoPlayerState } from "../types";
|
||||
import { VideoPlayerState, VideoPlayerTimeFormat } from "../types";
|
||||
|
||||
export type VideoInterfaceEvent = {
|
||||
popout: string | null;
|
||||
@@ -9,6 +9,8 @@ export type VideoInterfaceEvent = {
|
||||
isFocused: boolean;
|
||||
isFullscreen: boolean;
|
||||
popoutBounds: null | DOMRect;
|
||||
volumeChangedWithKeybind: boolean;
|
||||
timeFormat: VideoPlayerTimeFormat;
|
||||
};
|
||||
|
||||
function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent {
|
||||
@@ -18,6 +20,8 @@ function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent {
|
||||
isFocused: state.interface.isFocused,
|
||||
isFullscreen: state.interface.isFullscreen,
|
||||
popoutBounds: state.interface.popoutBounds,
|
||||
volumeChangedWithKeybind: state.interface.volumeChangedWithKeybind,
|
||||
timeFormat: state.interface.timeFormat,
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -16,7 +16,7 @@ export type VideoPlayerStateController = {
|
||||
setSeeking(active: boolean): void;
|
||||
exitFullscreen(): void;
|
||||
enterFullscreen(): void;
|
||||
setVolume(volume: number): void;
|
||||
setVolume(volume: number, isKeyboardEvent?: boolean): void;
|
||||
startAirplay(): void;
|
||||
setCaption(id: string, url: string): void;
|
||||
clearCaption(): void;
|
||||
|
@@ -22,14 +22,22 @@ export type VideoPlayerMeta = {
|
||||
}[];
|
||||
};
|
||||
|
||||
export enum VideoPlayerTimeFormat {
|
||||
REGULAR = 0,
|
||||
REMAINING = 1,
|
||||
}
|
||||
|
||||
export type VideoPlayerState = {
|
||||
// state related to the user interface
|
||||
interface: {
|
||||
isFullscreen: boolean;
|
||||
popout: string | null; // id of current popout (eg source select, episode select)
|
||||
isFocused: boolean; // is the video player the users focus? (shortcuts only works when its focused)
|
||||
volumeChangedWithKeybind: boolean; // has the volume recently been adjusted with the up/down arrows recently?
|
||||
volumeChangedWithKeybindDebounce: NodeJS.Timeout | null; // debounce for the duration of the "volume changed thingamajig"
|
||||
leftControlHovering: boolean; // is the cursor hovered over the left side of player controls
|
||||
popoutBounds: null | DOMRect; // bounding box of current popout
|
||||
timeFormat: VideoPlayerTimeFormat; // Time format of the video player
|
||||
};
|
||||
|
||||
// state related to the playing state of the media
|
||||
|
147
src/views/SettingsModal.tsx
Normal file
147
src/views/SettingsModal.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Dropdown } from "@/components/Dropdown";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Modal, ModalCard } from "@/components/layout/Modal";
|
||||
import { useSettings } from "@/state/settings";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CaptionCue } from "@/video/components/actions/CaptionRendererAction";
|
||||
import {
|
||||
CaptionLanguageOption,
|
||||
LangCode,
|
||||
captionLanguages,
|
||||
} from "@/setup/iso6391";
|
||||
import { useMemo } from "react";
|
||||
import { appLanguageOptions } from "@/setup/i18n";
|
||||
import CaptionColorSelector, {
|
||||
colors,
|
||||
} from "@/components/CaptionColorSelector";
|
||||
import { Slider } from "@/components/Slider";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
export default function SettingsModal(props: {
|
||||
onClose: () => void;
|
||||
show: boolean;
|
||||
}) {
|
||||
const {
|
||||
captionSettings,
|
||||
language,
|
||||
setLanguage,
|
||||
setCaptionLanguage,
|
||||
setCaptionBackgroundColor,
|
||||
setCaptionFontSize,
|
||||
} = useSettings();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const selectedCaptionLanguage = useMemo(
|
||||
() => captionLanguages.find((l) => l.id === captionSettings.language),
|
||||
[captionSettings.language]
|
||||
) as CaptionLanguageOption;
|
||||
const appLanguage = useMemo(
|
||||
() => appLanguageOptions.find((l) => l.id === language),
|
||||
[language]
|
||||
) as CaptionLanguageOption;
|
||||
const captionBackgroundOpacity = (
|
||||
(parseInt(captionSettings.style.backgroundColor.substring(7, 9), 16) /
|
||||
255) *
|
||||
100
|
||||
).toFixed(0);
|
||||
return (
|
||||
<Modal show={props.show}>
|
||||
<ModalCard className="text-white">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row justify-between">
|
||||
<span className="text-xl font-bold">{t("settings.title")}</span>
|
||||
<div
|
||||
onClick={() => props.onClose()}
|
||||
className="hover:cursor-pointer"
|
||||
>
|
||||
<Icon icon={Icons.X} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-10 lg:flex-row">
|
||||
<div className="lg:w-1/2">
|
||||
<div className="flex flex-col justify-between">
|
||||
<label className="text-md font-semibold">
|
||||
{t("settings.language")}
|
||||
</label>
|
||||
<Dropdown
|
||||
selectedItem={appLanguage}
|
||||
setSelectedItem={(val) => {
|
||||
i18n.changeLanguage(val.id);
|
||||
setLanguage(val.id as LangCode);
|
||||
}}
|
||||
options={appLanguageOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between">
|
||||
<label className="text-md font-semibold">
|
||||
{t("settings.captionLanguage")}
|
||||
</label>
|
||||
<Dropdown
|
||||
selectedItem={selectedCaptionLanguage}
|
||||
setSelectedItem={(val) => {
|
||||
setCaptionLanguage(val.id as LangCode);
|
||||
}}
|
||||
options={captionLanguages}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between">
|
||||
<Slider
|
||||
label={
|
||||
t(
|
||||
"videoPlayer.popouts.captionPreferences.fontSize"
|
||||
) as string
|
||||
}
|
||||
min={14}
|
||||
step={1}
|
||||
max={60}
|
||||
value={captionSettings.style.fontSize}
|
||||
onChange={(e) => setCaptionFontSize(e.target.valueAsNumber)}
|
||||
/>
|
||||
<Slider
|
||||
label={
|
||||
t(
|
||||
"videoPlayer.popouts.captionPreferences.opacity"
|
||||
) as string
|
||||
}
|
||||
step={1}
|
||||
min={0}
|
||||
max={255}
|
||||
valueDisplay={`${captionBackgroundOpacity}%`}
|
||||
value={parseInt(
|
||||
captionSettings.style.backgroundColor.substring(7, 9),
|
||||
16
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setCaptionBackgroundColor(e.target.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-row justify-between">
|
||||
<label className="font-bold" htmlFor="color">
|
||||
{t("videoPlayer.popouts.captionPreferences.color")}
|
||||
</label>
|
||||
<div className="flex flex-row gap-2">
|
||||
{colors.map((color) => (
|
||||
<CaptionColorSelector color={color} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<div className="flex w-full flex-col justify-center">
|
||||
<div className="flex aspect-video flex-col justify-end rounded bg-zinc-800">
|
||||
<div className="pointer-events-none flex w-full flex-col items-center transition-[bottom]">
|
||||
<CaptionCue
|
||||
scale={0.5}
|
||||
text={selectedCaptionLanguage.nativeName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="float-right mt-1 text-sm">v{conf().APP_VERSION}</div>
|
||||
</ModalCard>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@@ -66,6 +66,7 @@ export default function VideoTesterView() {
|
||||
source={video.streamUrl}
|
||||
type={videoType}
|
||||
quality={MWStreamQuality.QUNKNOWN}
|
||||
captions={[]}
|
||||
/>
|
||||
</VideoPlayer>
|
||||
</div>
|
||||
|
@@ -148,6 +148,7 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
|
||||
quality={props.stream.quality}
|
||||
embedId={props.stream.embedId}
|
||||
providerId={props.stream.providerId}
|
||||
captions={props.stream.captions}
|
||||
/>
|
||||
<ProgressListenerController
|
||||
startAt={firstStartTime.current}
|
||||
|
97
yarn.lock
97
yarn.lock
@@ -1301,6 +1301,80 @@
|
||||
estree-walker "^2.0.2"
|
||||
picomatch "^2.3.1"
|
||||
|
||||
"@sentry-internal/tracing@7.49.0":
|
||||
version "7.49.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.49.0.tgz#f589de565370884b9a13f82c98463de9b2d25dcd"
|
||||
integrity sha512-ESh3+ZneQk/3HESTUmIPNrW5GVPu/HrRJU+eAJJto74vm+6vP7zDn2YV2gJ1w18O/37nc7W/bVCgZJlhZ3cwew==
|
||||
dependencies:
|
||||
"@sentry/core" "7.49.0"
|
||||
"@sentry/types" "7.49.0"
|
||||
"@sentry/utils" "7.49.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/browser@7.49.0":
|
||||
version "7.49.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.49.0.tgz#5ce1cdb8d883c129d9a4e313c08a54c5ada4661b"
|
||||
integrity sha512-x2DekKkQoY7/dhBzE4J25mdQ978NtPBTVQb+uZqlF/t5mp4K44TAszmPqy8lC/CmVHkp7qcpRGSCIzeboUL4KA==
|
||||
dependencies:
|
||||
"@sentry-internal/tracing" "7.49.0"
|
||||
"@sentry/core" "7.49.0"
|
||||
"@sentry/replay" "7.49.0"
|
||||
"@sentry/types" "7.49.0"
|
||||
"@sentry/utils" "7.49.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/core@7.49.0":
|
||||
version "7.49.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.49.0.tgz#340d059f5efeff1a3359fef66d0c8e34e79ac992"
|
||||
integrity sha512-AlSnCYgfEbvK8pkNluUkmdW/cD9UpvOVCa+ERQswXNRkAv5aDGCL6Ihv6fnIajE++BYuwZh0+HwZUBVKTFzoZg==
|
||||
dependencies:
|
||||
"@sentry/types" "7.49.0"
|
||||
"@sentry/utils" "7.49.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/integrations@^7.49.0":
|
||||
version "7.49.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.49.0.tgz#e123f687e0abe10d3428027e3879ce231503fc2f"
|
||||
integrity sha512-qsEVkcZjw+toFGnzsVo+Cozz+hMK9LugzkfJyOFL+CyiEx9MfkEmsvRpZe1ETEWKe/VZylYU27NQzl6UNuAUjw==
|
||||
dependencies:
|
||||
"@sentry/types" "7.49.0"
|
||||
"@sentry/utils" "7.49.0"
|
||||
localforage "^1.8.1"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/react@^7.49.0":
|
||||
version "7.49.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-7.49.0.tgz#9a31808d4232d3010019e09d7c706b3d4fe54960"
|
||||
integrity sha512-s+ROJr1tP9zVBmoOn94JM+fu2TuoJKxkSXTEUOKoQ9P6P5ROzpDqTzHRGk6u4OjZTy5tftRyEqBGM2Iaf9Y+UA==
|
||||
dependencies:
|
||||
"@sentry/browser" "7.49.0"
|
||||
"@sentry/types" "7.49.0"
|
||||
"@sentry/utils" "7.49.0"
|
||||
hoist-non-react-statics "^3.3.2"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/replay@7.49.0":
|
||||
version "7.49.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.49.0.tgz#c7f16bc3ca0c5911f641738f8894eb596c5da00d"
|
||||
integrity sha512-UY3bHoBDPOu4Dpq3m3oxNjLrq09NiFVYUfrTN4QOq1Am2SA04XbuCj/YZ+jNVy/NrFtoz9cTovK6oQbNw53jog==
|
||||
dependencies:
|
||||
"@sentry/core" "7.49.0"
|
||||
"@sentry/types" "7.49.0"
|
||||
"@sentry/utils" "7.49.0"
|
||||
|
||||
"@sentry/types@7.49.0":
|
||||
version "7.49.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.49.0.tgz#2c217091e13dc373682f5be2e9b5baed9d2ae695"
|
||||
integrity sha512-9yXXh7iv76+O6h2ONUVx0wsL1auqJFWez62mTjWk4350SgMmWp/zUkBxnVXhmcYqscz/CepC+Loz9vITLXtgxg==
|
||||
|
||||
"@sentry/utils@7.49.0":
|
||||
version "7.49.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.49.0.tgz#b1b3a2af52067dd27e660c7c3062a31cdf4b94f9"
|
||||
integrity sha512-JdC9yGnOgev4ISJVwmIoFsk8Zx0psDZJAj2DV7x4wMZsO6QK+YjC7G3mUED/S5D5lsrkBZ/3uvQQhr8DQI4UcQ==
|
||||
dependencies:
|
||||
"@sentry/types" "7.49.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@surma/rollup-plugin-off-main-thread@^2.2.3":
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053"
|
||||
@@ -3143,7 +3217,7 @@ hls.js@^1.0.7:
|
||||
resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.3.5.tgz#0e8b0799ecf2feb7ba199f5e95f35ba9552e04f4"
|
||||
integrity sha512-uybAvKS6uDe0MnWNEPnO0krWVr+8m2R0hJ/viql8H3MVK+itq8gGQuIYoFHL3rECkIpNH98Lw8YuuWMKZxp3Ew==
|
||||
|
||||
hoist-non-react-statics@^3.1.0:
|
||||
hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.2:
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
|
||||
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
|
||||
@@ -3217,6 +3291,11 @@ ignore@^5.2.0:
|
||||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
|
||||
integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
|
||||
|
||||
immediate@~3.0.5:
|
||||
version "3.0.6"
|
||||
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
|
||||
integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==
|
||||
|
||||
import-fresh@^3.0.0, import-fresh@^3.2.1:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
|
||||
@@ -3642,6 +3721,13 @@ levn@~0.3.0:
|
||||
prelude-ls "~1.1.2"
|
||||
type-check "~0.3.2"
|
||||
|
||||
lie@3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
|
||||
integrity sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==
|
||||
dependencies:
|
||||
immediate "~3.0.5"
|
||||
|
||||
lilconfig@^2.0.5, lilconfig@^2.0.6:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52"
|
||||
@@ -3652,6 +3738,13 @@ local-pkg@^0.4.2:
|
||||
resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.3.tgz#0ff361ab3ae7f1c19113d9bb97b98b905dbc4963"
|
||||
integrity sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==
|
||||
|
||||
localforage@^1.8.1:
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4"
|
||||
integrity sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==
|
||||
dependencies:
|
||||
lie "3.1.1"
|
||||
|
||||
locate-path@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
|
||||
@@ -5021,7 +5114,7 @@ tsconfig-paths@^3.14.1:
|
||||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
tslib@^1.8.1:
|
||||
tslib@^1.8.1, tslib@^1.9.3:
|
||||
version "1.14.1"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
||||
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
||||
|
Reference in New Issue
Block a user