mirror of
https://github.com/movie-web/movie-web.git
synced 2024-11-14 19:15:05 +01:00
Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-file-type-control
This commit is contained in:
commit
f12f53d32c
@ -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));
|
||||||
|
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"
|
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 }) =>
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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
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",
|
"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",
|
||||||
|
@ -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",
|
||||||
|
@ -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;
|
||||||
});
|
});
|
||||||
|
@ -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();
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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]);
|
||||||
|
@ -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
|
||||||
|
@ -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!
|
||||||
|
@ -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 (
|
||||||
|
<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}>
|
<div className={props.className}>
|
||||||
<p className="select-none text-white">
|
<p className="select-none text-white">{formattedTime}</p>
|
||||||
{time} {props.noDuration ? "" : `/ ${duration}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
</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 { 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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
|
@ -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: {
|
||||||
|
@ -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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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
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}
|
source={video.streamUrl}
|
||||||
type={videoType}
|
type={videoType}
|
||||||
quality={MWStreamQuality.QUNKNOWN}
|
quality={MWStreamQuality.QUNKNOWN}
|
||||||
|
captions={[]}
|
||||||
/>
|
/>
|
||||||
</VideoPlayer>
|
</VideoPlayer>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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}
|
||||||
|
Loading…
Reference in New Issue
Block a user