Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-file-type-control

This commit is contained in:
frost768 2023-04-20 22:22:10 +03:00
commit f12f53d32c
29 changed files with 1805 additions and 80 deletions

View File

@ -4,6 +4,10 @@ import DOMPurify from "dompurify";
import { parse, detect, list, convert } from "subsrt-ts"; import { parse, detect, list, convert } from "subsrt-ts";
import { ContentCaption } from "subsrt-ts/dist/types/handler"; 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 subtitleTypeList = list().map((type) => `.${type}`);
export function isSupportedSubtitle(url: string): boolean { export function isSupportedSubtitle(url: string): boolean {
return subtitleTypeList.some((type) => url.endsWith(type)); return subtitleTypeList.some((type) => url.endsWith(type));

View 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>
);
}

View File

@ -37,7 +37,7 @@ export function Dropdown(props: DropdownProps) {
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<Listbox.Options className="absolute bottom-11 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:bottom-10 sm:text-sm"> <Listbox.Options className="absolute top-10 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:top-10 sm:text-sm">
{props.options.map((opt) => ( {props.options.map((opt) => (
<Listbox.Option <Listbox.Option
className={({ active }) => className={({ active }) =>

View File

@ -35,9 +35,14 @@ export function Modal(props: Props) {
); );
} }
export function ModalCard(props: { children?: ReactNode }) { export function ModalCard(props: { className?: string; children?: ReactNode }) {
return ( return (
<div className="relative mx-2 max-w-[600px] overflow-hidden rounded-lg bg-denim-200 px-10 py-10"> <div
className={[
"relative mx-2 w-[500px] overflow-hidden rounded-lg bg-denim-300 px-10 py-10 sm:w-[500px] md:w-[500px] lg:w-[1000px]",
props.className ?? "",
].join(" ")}
>
{props.children} {props.children}
</div> </div>
); );

View File

@ -1,9 +1,10 @@
import { ReactNode } from "react"; import { ReactNode, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { IconPatch } from "@/components/buttons/IconPatch"; import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon"; import { Icons } from "@/components/Icon";
import { conf } from "@/setup/config"; import { conf } from "@/setup/config";
import { useBannerSize } from "@/hooks/useBanner"; import { useBannerSize } from "@/hooks/useBanner";
import SettingsModal from "@/views/SettingsModal";
import { BrandPill } from "./BrandPill"; import { BrandPill } from "./BrandPill";
export interface NavigationProps { export interface NavigationProps {
@ -13,7 +14,7 @@ export interface NavigationProps {
export function Navigation(props: NavigationProps) { export function Navigation(props: NavigationProps) {
const bannerHeight = useBannerSize(); const bannerHeight = useBannerSize();
const [showModal, setShowModal] = useState(false);
return ( return (
<div <div
className="fixed left-0 right-0 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent" className="fixed left-0 right-0 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent"
@ -42,6 +43,14 @@ export function Navigation(props: NavigationProps) {
props.children ? "hidden sm:flex" : "flex" props.children ? "hidden sm:flex" : "flex"
} relative flex-row gap-4`} } relative flex-row gap-4`}
> >
<IconPatch
className="text-2xl text-white"
icon={Icons.GEAR}
clickable
onClick={() => {
setShowModal(true);
}}
/>
<a <a
href={conf().DISCORD_LINK} href={conf().DISCORD_LINK}
target="_blank" target="_blank"
@ -60,6 +69,7 @@ export function Navigation(props: NavigationProps) {
</a> </a>
</div> </div>
</div> </div>
<SettingsModal show={showModal} onClose={() => setShowModal(false)} />
</div> </div>
); );
} }

View File

@ -4,7 +4,13 @@ import LanguageDetector from "i18next-browser-languagedetector";
// Languages // Languages
import en from "./locales/en/translation.json"; import en from "./locales/en/translation.json";
import { captionLanguages } from "./iso6391";
const locales = {
en: {
translation: en,
},
};
i18n i18n
// detect user language // detect user language
// learn more: https://github.com/i18next/i18next-browser-languageDetector // learn more: https://github.com/i18next/i18next-browser-languageDetector
@ -15,16 +21,14 @@ i18n
// for all options read: https://www.i18next.com/overview/configuration-options // for all options read: https://www.i18next.com/overview/configuration-options
.init({ .init({
fallbackLng: "en", fallbackLng: "en",
resources: locales,
resources: {
en: {
translation: en,
},
},
interpolation: { interpolation: {
escapeValue: false, // not needed for react as it escapes by default escapeValue: false, // not needed for react as it escapes by default
}, },
}); });
export const appLanguageOptions = captionLanguages.filter((x) => {
return Object.keys(locales).includes(x.id);
});
export default i18n; export default i18n;

1326
src/setup/iso6391.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,8 @@
"backToHome": "Back to home", "backToHome": "Back to home",
"backToHomeShort": "Back", "backToHomeShort": "Back",
"seasonAndEpisode": "S{{season}} E{{episode}}", "seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} left",
"finishAt": "Finish at {{timeFinished}}",
"buttons": { "buttons": {
"episodes": "Episodes", "episodes": "Episodes",
"source": "Source", "source": "Source",
@ -104,6 +106,11 @@
"fatalError": "The video player encounted a fatal error, please report it to the <0>Discord server</0> or on <1>GitHub</1>." "fatalError": "The video player encounted a fatal error, please report it to the <0>Discord server</0> or on <1>GitHub</1>."
} }
}, },
"settings": {
"title": "Settings",
"language":"Language",
"captionLanguage": "Caption Language"
},
"v3": { "v3": {
"newSiteTitle": "New version now released!", "newSiteTitle": "New version now released!",
"newDomain": "https://movie-web.app", "newDomain": "https://movie-web.app",

View File

@ -39,13 +39,16 @@
"backToHome": "Retour à la page d'accueil", "backToHome": "Retour à la page d'accueil",
"backToHomeShort": "Retour", "backToHomeShort": "Retour",
"seasonAndEpisode": "S{{season}} E{{episode}}", "seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} restant",
"finishAt": "Terminer à {{timeFinished}}",
"buttons": { "buttons": {
"episodes": "Épisodes", "episodes": "Épisodes",
"source": "Source", "source": "Source",
"captions": "Sous-titres", "captions": "Sous-titres",
"download": "Télécharger", "download": "Télécharger",
"settings": "Paramètres", "settings": "Paramètres",
"pictureInPicture": "Image dans l'image" "pictureInPicture": "Image dans l'image",
"playbackSpeed": "Vitesse"
}, },
"popouts": { "popouts": {
"sources": "Sources", "sources": "Sources",

View File

@ -1,14 +1,16 @@
import { useStore } from "@/utils/storage"; import { useStore } from "@/utils/storage";
import { createContext, ReactNode, useContext, useMemo } from "react"; import { createContext, ReactNode, useContext, useMemo } from "react";
import { LangCode } from "@/setup/iso6391";
import { SettingsStore } from "./store"; import { SettingsStore } from "./store";
import { MWSettingsData } from "./types"; import { MWSettingsData } from "./types";
interface MWSettingsDataSetters { interface MWSettingsDataSetters {
setLanguage(language: string): void; setLanguage(language: LangCode): void;
setCaptionLanguage(language: LangCode): void;
setCaptionDelay(delay: number): void; setCaptionDelay(delay: number): void;
setCaptionColor(color: string): void; setCaptionColor(color: string): void;
setCaptionFontSize(size: number): void; setCaptionFontSize(size: number): void;
setCaptionBackgroundColor(backgroundColor: string): void; setCaptionBackgroundColor(backgroundColor: number): void;
} }
type MWSettingsDataWrapper = MWSettingsData & MWSettingsDataSetters; type MWSettingsDataWrapper = MWSettingsData & MWSettingsDataSetters;
const SettingsContext = createContext<MWSettingsDataWrapper>(null as any); const SettingsContext = createContext<MWSettingsDataWrapper>(null as any);
@ -17,7 +19,6 @@ export function SettingsProvider(props: { children: ReactNode }) {
return Math.max(min, Math.min(value, max)); return Math.max(min, Math.min(value, max));
} }
const [settings, setSettings] = useStore(SettingsStore); const [settings, setSettings] = useStore(SettingsStore);
const context: MWSettingsDataWrapper = useMemo(() => { const context: MWSettingsDataWrapper = useMemo(() => {
const settingsContext: MWSettingsDataWrapper = { const settingsContext: MWSettingsDataWrapper = {
...settings, ...settings,
@ -29,6 +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) { setCaptionDelay(delay: number) {
setSettings((oldSettings) => { setSettings((oldSettings) => {
const captionSettings = oldSettings.captionSettings; const captionSettings = oldSettings.captionSettings;
@ -56,7 +65,10 @@ export function SettingsProvider(props: { children: ReactNode }) {
setCaptionBackgroundColor(backgroundColor) { setCaptionBackgroundColor(backgroundColor) {
setSettings((oldSettings) => { setSettings((oldSettings) => {
const style = oldSettings.captionSettings.style; const style = oldSettings.captionSettings.style;
style.backgroundColor = backgroundColor; style.backgroundColor = `${style.backgroundColor.substring(
0,
7
)}${backgroundColor.toString(16).padStart(2, "0")}`;
const newSettings = oldSettings; const newSettings = oldSettings;
return newSettings; return newSettings;
}); });

View File

@ -1,11 +1,11 @@
import { createVersionedStore } from "@/utils/storage"; import { createVersionedStore } from "@/utils/storage";
import { MWSettingsData } from "./types"; import { MWSettingsData, MWSettingsDataV1 } from "./types";
export const SettingsStore = createVersionedStore<MWSettingsData>() export const SettingsStore = createVersionedStore<MWSettingsData>()
.setKey("mw-settings") .setKey("mw-settings")
.addVersion({ .addVersion({
version: 0, version: 0,
create(): MWSettingsData { create(): MWSettingsDataV1 {
return { return {
language: "en", language: "en",
captionSettings: { captionSettings: {
@ -18,5 +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(); .build();

View File

@ -1,3 +1,5 @@
import { LangCode } from "@/setup/iso6391";
export interface CaptionStyleSettings { export interface CaptionStyleSettings {
color: string; color: string;
/** /**
@ -7,7 +9,7 @@ export interface CaptionStyleSettings {
backgroundColor: string; backgroundColor: string;
} }
export interface CaptionSettings { export interface CaptionSettingsV1 {
/** /**
* Range is [-10, 10]s * Range is [-10, 10]s
*/ */
@ -15,7 +17,20 @@ export interface CaptionSettings {
style: CaptionStyleSettings; style: CaptionStyleSettings;
} }
export interface CaptionSettings {
language: LangCode;
/**
* Range is [-10, 10]s
*/
delay: number;
style: CaptionStyleSettings;
}
export interface MWSettingsDataV1 {
language: LangCode;
captionSettings: CaptionSettingsV1;
}
export interface MWSettingsData { export interface MWSettingsData {
language: string; language: LangCode;
captionSettings: CaptionSettings; captionSettings: CaptionSettings;
} }

View File

@ -31,6 +31,7 @@ import { PictureInPictureAction } from "@/video/components/actions/PictureInPict
import { CaptionRendererAction } from "./actions/CaptionRendererAction"; import { CaptionRendererAction } from "./actions/CaptionRendererAction";
import { SettingsAction } from "./actions/SettingsAction"; import { SettingsAction } from "./actions/SettingsAction";
import { DividerAction } from "./actions/DividerAction"; import { DividerAction } from "./actions/DividerAction";
import { VolumeAdjustedAction } from "./actions/VolumeAdjustedAction";
type Props = VideoPlayerBaseProps; type Props = VideoPlayerBaseProps;
@ -91,6 +92,7 @@ export function VideoPlayer(props: Props) {
<> <>
<KeyboardShortcutsAction /> <KeyboardShortcutsAction />
<PageTitleAction /> <PageTitleAction />
<VolumeAdjustedAction />
<VideoPlayerError onGoBack={props.onGoBack}> <VideoPlayerError onGoBack={props.onGoBack}>
<BackdropAction onBackdropChange={onBackdropChange}> <BackdropAction onBackdropChange={onBackdropChange}>
<CenterPosition> <CenterPosition>

View File

@ -24,18 +24,16 @@ export function BackdropAction(props: BackdropActionProps) {
const handleMouseMove = useCallback(() => { const handleMouseMove = useCallback(() => {
if (!moved) { if (!moved) {
setTimeout(() => { setTimeout(() => {
// If NOT a touch, set moved to true
const isTouch = Date.now() - lastTouchEnd.current < 200; const isTouch = Date.now() - lastTouchEnd.current < 200;
if (!isTouch) { if (!isTouch) setMoved(true);
setMoved(true);
}
}, 20); }, 20);
return;
} }
// remove after all // remove after all
if (timeout.current) clearTimeout(timeout.current); if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(() => { timeout.current = setTimeout(() => {
if (moved) setMoved(false); setMoved(false);
timeout.current = null; timeout.current = null;
}, 3000); }, 3000);
}, [setMoved, moved]); }, [setMoved, moved]);

View File

@ -8,7 +8,7 @@ import { useVideoPlayerDescriptor } from "../../state/hooks";
import { useProgress } from "../../state/logic/progress"; import { useProgress } from "../../state/logic/progress";
import { useSource } from "../../state/logic/source"; import { useSource } from "../../state/logic/source";
function CaptionCue({ text }: { text?: string }) { export function CaptionCue({ text, scale }: { text?: string; scale?: number }) {
const { captionSettings } = useSettings(); const { captionSettings } = useSettings();
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />"); const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />");
@ -25,6 +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)]" className="pointer-events-none mb-1 select-none rounded px-4 py-1 text-center [text-shadow:0_2px_4px_rgba(0,0,0,0.5)]"
style={{ style={{
...captionSettings.style, ...captionSettings.style,
fontSize: captionSettings.style.fontSize * (scale ?? 1),
}} }}
> >
<span <span

View File

@ -65,12 +65,12 @@ export function KeyboardShortcutsAction() {
// Decrease volume // Decrease volume
case "arrowdown": case "arrowdown":
controls.setVolume(Math.max(mediaPlaying.volume - 0.1, 0)); controls.setVolume(Math.max(mediaPlaying.volume - 0.1, 0), true);
break; break;
// Increase volume // Increase volume
case "arrowup": case "arrowup":
controls.setVolume(Math.min(mediaPlaying.volume + 0.1, 1)); controls.setVolume(Math.min(mediaPlaying.volume + 0.1, 1), true);
break; break;
// Do a barrel Roll! // Do a barrel Roll!

View File

@ -1,6 +1,11 @@
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useTranslation } from "react-i18next";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useProgress } from "@/video/state/logic/progress"; import { useProgress } from "@/video/state/logic/progress";
import { 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 { function durationExceedsHour(secs: number): boolean {
return secs > 60 * 60; return secs > 60 * 60;
@ -37,19 +42,71 @@ export function TimeAction(props: Props) {
const descriptor = useVideoPlayerDescriptor(); const descriptor = useVideoPlayerDescriptor();
const videoTime = useProgress(descriptor); const videoTime = useProgress(descriptor);
const mediaPlaying = useMediaPlaying(descriptor); const mediaPlaying = useMediaPlaying(descriptor);
const { setTimeFormat } = useControls(descriptor);
const { timeFormat } = useInterface(descriptor);
const { isMobile } = useIsMobile();
const { t } = useTranslation();
const hasHours = durationExceedsHour(videoTime.duration); const hasHours = durationExceedsHour(videoTime.duration);
const time = formatSeconds(
const currentTime = formatSeconds(
mediaPlaying.isDragSeeking ? videoTime.draggingTime : videoTime.time, mediaPlaying.isDragSeeking ? videoTime.draggingTime : videoTime.time,
hasHours hasHours
); );
const duration = formatSeconds(videoTime.duration, hasHours); const duration = formatSeconds(videoTime.duration, hasHours);
const timeLeft = formatSeconds(
(videoTime.duration - videoTime.time) / mediaPlaying.playbackSpeed,
hasHours
);
const timeFinished = new Date(
new Date().getTime() +
(videoTime.duration * 1000) / mediaPlaying.playbackSpeed
).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "numeric",
hour12: true,
});
const formattedTimeFinished = ` - ${t("videoPlayer.finishAt", {
timeFinished,
})}`;
let formattedTime: string;
if (timeFormat === VideoPlayerTimeFormat.REGULAR) {
formattedTime = `${currentTime} ${props.noDuration ? "" : `/ ${duration}`}`;
} else if (timeFormat === VideoPlayerTimeFormat.REMAINING && !isMobile) {
formattedTime = `${t("videoPlayer.timeLeft", {
timeLeft,
})}${videoTime.time === videoTime.duration ? "" : formattedTimeFinished} `;
} else if (timeFormat === VideoPlayerTimeFormat.REMAINING && isMobile) {
formattedTime = `-${timeLeft}`;
} else {
formattedTime = "";
}
return ( return (
<div className={props.className}> <button
<p className="select-none text-white"> type="button"
{time} {props.noDuration ? "" : `/ ${duration}`} className={[
</p> "group pointer-events-auto text-white transition-transform duration-100 active:scale-110",
</div> ].join(" ")}
onClick={() => {
setTimeFormat(
timeFormat === VideoPlayerTimeFormat.REGULAR
? VideoPlayerTimeFormat.REMAINING
: VideoPlayerTimeFormat.REGULAR
);
}}
>
<div
className={[
"flex items-center justify-center rounded-full bg-denim-600 bg-opacity-0 p-2 transition-colors duration-100 group-hover:bg-opacity-50 group-active:bg-denim-500 group-active:bg-opacity-100 sm:px-4",
].join(" ")}
>
<div className={props.className}>
<p className="select-none text-white">{formattedTime}</p>
</div>
</div>
</button>
); );
} }

View 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>
);
}

View File

@ -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 { useInitialized } from "@/video/components/hooks/useInitialized";
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls"; import { useControls } from "@/video/state/logic/controls";
@ -10,6 +17,19 @@ interface SourceControllerProps {
quality: MWStreamQuality; quality: MWStreamQuality;
providerId?: string; providerId?: string;
embedId?: 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) { export function SourceController(props: SourceControllerProps) {
@ -17,13 +37,35 @@ export function SourceController(props: SourceControllerProps) {
const controls = useControls(descriptor); const controls = useControls(descriptor);
const { initialized } = useInitialized(descriptor); const { initialized } = useInitialized(descriptor);
const didInitialize = useRef<boolean>(false); const didInitialize = useRef<boolean>(false);
const { captionSettings } = useSettings();
useEffect(() => { useEffect(() => {
if (didInitialize.current) return; if (didInitialize.current) return;
if (!initialized) return; if (!initialized) return;
controls.setSource(props); 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; didInitialize.current = true;
}, [props, controls, initialized]); }, [props, controls, initialized, captionSettings.language]);
return null; return null;
} }

View File

@ -1,5 +1,7 @@
import { import {
customCaption,
getCaptionUrl, getCaptionUrl,
makeCaptionId,
parseSubtitles, parseSubtitles,
subtitleTypeList, subtitleTypeList,
} from "@/backend/helpers/captions"; } from "@/backend/helpers/captions";
@ -17,11 +19,6 @@ import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils"; 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: { export function CaptionSelectionPopout(props: {
router: ReturnType<typeof useFloatingRouter>; router: ReturnType<typeof useFloatingRouter>;
prefix: string; prefix: string;

View File

@ -4,8 +4,10 @@ import { useFloatingRouter } from "@/hooks/useFloatingRouter";
import { useSettings } from "@/state/settings"; import { useSettings } from "@/state/settings";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon";
import { Slider } from "@/components/Slider"; import { Slider } from "@/components/Slider";
import CaptionColorSelector, {
colors,
} from "@/components/CaptionColorSelector";
export function CaptionSettingsPopout(props: { export function CaptionSettingsPopout(props: {
router: ReturnType<typeof useFloatingRouter>; router: ReturnType<typeof useFloatingRouter>;
@ -16,11 +18,9 @@ export function CaptionSettingsPopout(props: {
const { const {
captionSettings, captionSettings,
setCaptionBackgroundColor, setCaptionBackgroundColor,
setCaptionColor,
setCaptionDelay, setCaptionDelay,
setCaptionFontSize, setCaptionFontSize,
} = useSettings(); } = useSettings();
const colors = ["#ffffff", "#00ffff", "#ffff00"];
return ( return (
<FloatingView {...props.router.pageProps(props.prefix)} width={375}> <FloatingView {...props.router.pageProps(props.prefix)} width={375}>
<FloatingCardView.Header <FloatingCardView.Header
@ -39,7 +39,7 @@ export function CaptionSettingsPopout(props: {
onChange={(e) => setCaptionDelay(e.target.valueAsNumber)} onChange={(e) => setCaptionDelay(e.target.valueAsNumber)}
/> />
<Slider <Slider
label="Size" label={t("videoPlayer.popouts.captionPreferences.fontSize") as string}
min={14} min={14}
step={1} step={1}
max={60} max={60}
@ -63,14 +63,7 @@ export function CaptionSettingsPopout(props: {
captionSettings.style.backgroundColor.substring(7, 9), captionSettings.style.backgroundColor.substring(7, 9),
16 16
)} )}
onChange={(e) => onChange={(e) => setCaptionBackgroundColor(e.target.valueAsNumber)}
setCaptionBackgroundColor(
`${captionSettings.style.backgroundColor.substring(
0,
7
)}${e.target.valueAsNumber.toString(16)}`
)
}
/> />
<div className="flex flex-row justify-between"> <div className="flex flex-row justify-between">
<label className="font-bold" htmlFor="color"> <label className="font-bold" htmlFor="color">
@ -78,26 +71,7 @@ export function CaptionSettingsPopout(props: {
</label> </label>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
{colors.map((color) => ( {colors.map((color) => (
<div <CaptionColorSelector color={color} />
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>
))} ))}
</div> </div>
</div> </div>

View File

@ -32,6 +32,9 @@ function initPlayer(): VideoPlayerState {
isFocused: false, isFocused: false,
leftControlHovering: false, leftControlHovering: false,
popoutBounds: null, popoutBounds: null,
volumeChangedWithKeybind: false,
volumeChangedWithKeybindDebounce: null,
timeFormat: 0,
}, },
mediaPlaying: { mediaPlaying: {

View File

@ -1,7 +1,7 @@
import { updateInterface } from "@/video/state/logic/interface"; import { updateInterface } from "@/video/state/logic/interface";
import { updateMeta } from "@/video/state/logic/meta"; import { updateMeta } from "@/video/state/logic/meta";
import { updateProgress } from "@/video/state/logic/progress"; 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 { getPlayerState } from "../cache";
import { VideoPlayerStateController } from "../providers/providerTypes"; import { VideoPlayerStateController } from "../providers/providerTypes";
@ -15,6 +15,7 @@ export type ControlMethods = {
setDraggingTime(num: number): void; setDraggingTime(num: number): void;
togglePictureInPicture(): void; togglePictureInPicture(): void;
setPlaybackSpeed(num: number): void; setPlaybackSpeed(num: number): void;
setTimeFormat(num: VideoPlayerTimeFormat): void;
}; };
export function useControls( export function useControls(
@ -48,8 +49,20 @@ export function useControls(
enterFullscreen() { enterFullscreen() {
state.stateProvider?.enterFullscreen(); state.stateProvider?.enterFullscreen();
}, },
setVolume(volume) { setVolume(volume, isKeyboardEvent = false) {
state.stateProvider?.setVolume(volume); 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() { startAirplay() {
state.stateProvider?.startAirplay(); state.stateProvider?.startAirplay();
@ -110,5 +123,9 @@ export function useControls(
state.stateProvider?.setPlaybackSpeed(num); state.stateProvider?.setPlaybackSpeed(num);
updateInterface(descriptor, state); updateInterface(descriptor, state);
}, },
setTimeFormat(format) {
state.interface.timeFormat = format;
updateInterface(descriptor, state);
},
}; };
} }

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getPlayerState } from "../cache"; import { getPlayerState } from "../cache";
import { listenEvent, sendEvent, unlistenEvent } from "../events"; import { listenEvent, sendEvent, unlistenEvent } from "../events";
import { VideoPlayerState } from "../types"; import { VideoPlayerState, VideoPlayerTimeFormat } from "../types";
export type VideoInterfaceEvent = { export type VideoInterfaceEvent = {
popout: string | null; popout: string | null;
@ -9,6 +9,8 @@ export type VideoInterfaceEvent = {
isFocused: boolean; isFocused: boolean;
isFullscreen: boolean; isFullscreen: boolean;
popoutBounds: null | DOMRect; popoutBounds: null | DOMRect;
volumeChangedWithKeybind: boolean;
timeFormat: VideoPlayerTimeFormat;
}; };
function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent { function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent {
@ -18,6 +20,8 @@ function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent {
isFocused: state.interface.isFocused, isFocused: state.interface.isFocused,
isFullscreen: state.interface.isFullscreen, isFullscreen: state.interface.isFullscreen,
popoutBounds: state.interface.popoutBounds, popoutBounds: state.interface.popoutBounds,
volumeChangedWithKeybind: state.interface.volumeChangedWithKeybind,
timeFormat: state.interface.timeFormat,
}; };
} }

View File

@ -16,7 +16,7 @@ export type VideoPlayerStateController = {
setSeeking(active: boolean): void; setSeeking(active: boolean): void;
exitFullscreen(): void; exitFullscreen(): void;
enterFullscreen(): void; enterFullscreen(): void;
setVolume(volume: number): void; setVolume(volume: number, isKeyboardEvent?: boolean): void;
startAirplay(): void; startAirplay(): void;
setCaption(id: string, url: string): void; setCaption(id: string, url: string): void;
clearCaption(): void; clearCaption(): void;

View File

@ -22,14 +22,22 @@ export type VideoPlayerMeta = {
}[]; }[];
}; };
export enum VideoPlayerTimeFormat {
REGULAR = 0,
REMAINING = 1,
}
export type VideoPlayerState = { export type VideoPlayerState = {
// state related to the user interface // state related to the user interface
interface: { interface: {
isFullscreen: boolean; isFullscreen: boolean;
popout: string | null; // id of current popout (eg source select, episode select) 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) 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 leftControlHovering: boolean; // is the cursor hovered over the left side of player controls
popoutBounds: null | DOMRect; // bounding box of current popout 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 // state related to the playing state of the media

147
src/views/SettingsModal.tsx Normal file
View 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>
);
}

View File

@ -66,6 +66,7 @@ export default function VideoTesterView() {
source={video.streamUrl} source={video.streamUrl}
type={videoType} type={videoType}
quality={MWStreamQuality.QUNKNOWN} quality={MWStreamQuality.QUNKNOWN}
captions={[]}
/> />
</VideoPlayer> </VideoPlayer>
</div> </div>

View File

@ -148,6 +148,7 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
quality={props.stream.quality} quality={props.stream.quality}
embedId={props.stream.embedId} embedId={props.stream.embedId}
providerId={props.stream.providerId} providerId={props.stream.providerId}
captions={props.stream.captions}
/> />
<ProgressListenerController <ProgressListenerController
startAt={firstStartTime.current} startAt={firstStartTime.current}