mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-20 14:37:43 +01:00
settings modal
This commit is contained in:
parent
c2b52d3db8
commit
9e961223f6
9 changed files with 228 additions and 20 deletions
|
@ -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 max-w-[600px] overflow-hidden rounded-lg bg-denim-200 px-10 py-10",
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -99,6 +99,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",
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
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;
|
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,16 +18,15 @@ 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,
|
||||||
setLanguage(language) {
|
setCaptionLanguage(language) {
|
||||||
setSettings((oldSettings) => {
|
setSettings((oldSettings) => {
|
||||||
return {
|
const captionSettings = oldSettings.captionSettings;
|
||||||
...oldSettings,
|
captionSettings.language = language;
|
||||||
language,
|
const newSettings = oldSettings;
|
||||||
};
|
return newSettings;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
setCaptionDelay(delay: number) {
|
setCaptionDelay(delay: number) {
|
||||||
|
@ -56,7 +56,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,29 @@ export const SettingsStore = createVersionedStore<MWSettingsData>()
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
migrate(data: MWSettingsDataV1): MWSettingsData {
|
||||||
|
return {
|
||||||
|
captionSettings: {
|
||||||
|
language: "none",
|
||||||
|
...data.captionSettings,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addVersion({
|
||||||
|
version: 1,
|
||||||
|
create(): MWSettingsData {
|
||||||
|
return {
|
||||||
|
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,19 @@ 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;
|
|
||||||
captionSettings: CaptionSettings;
|
captionSettings: CaptionSettings;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 />");
|
||||||
|
|
||||||
|
@ -22,9 +22,14 @@ function CaptionCue({ text }: { text?: string }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
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)]",
|
||||||
|
].join(" ")}
|
||||||
style={{
|
style={{
|
||||||
...captionSettings.style,
|
...captionSettings.style,
|
||||||
|
fontSize: !scale
|
||||||
|
? captionSettings.style.fontSize
|
||||||
|
: captionSettings.style.fontSize * scale,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|
142
src/views/SettingsModal.tsx
Normal file
142
src/views/SettingsModal.tsx
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
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 { Slider } from "@/video/components/popouts/CaptionSettingsPopout";
|
||||||
|
import { appLanguageOptions } from "@/setup/i18n";
|
||||||
|
import { LangCode, captionLanguages } from "@/setup/iso6391";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export default function SettingsModal(props: {
|
||||||
|
onClose: () => void;
|
||||||
|
show: boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
captionSettings,
|
||||||
|
setCaptionLanguage,
|
||||||
|
setCaptionBackgroundColor,
|
||||||
|
setCaptionColor,
|
||||||
|
setCaptionFontSize,
|
||||||
|
} = useSettings();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
const colors = ["#ffffff", "#00ffff", "#ffff00"];
|
||||||
|
const selectedCaptionLanguage = useMemo(
|
||||||
|
() => captionLanguages.find((l) => l.id === captionSettings.language)!,
|
||||||
|
[captionSettings.language]
|
||||||
|
);
|
||||||
|
const captionBackgroundOpacity = (
|
||||||
|
(parseInt(captionSettings.style.backgroundColor.substring(7, 9), 16) /
|
||||||
|
255) *
|
||||||
|
100
|
||||||
|
).toFixed(0);
|
||||||
|
return (
|
||||||
|
<Modal show={props.show}>
|
||||||
|
<ModalCard className="max-w-[800px] bg-ash-300 text-white">
|
||||||
|
<div className="flex w-full 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 justify-between gap-10 max-sm:flex-col">
|
||||||
|
<div className="flex flex-col justify-between">
|
||||||
|
<label className="text-md font-semibold">
|
||||||
|
{t("settings.language")}
|
||||||
|
</label>
|
||||||
|
<Dropdown
|
||||||
|
selectedItem={
|
||||||
|
appLanguageOptions.find((l) => l.id === i18n.language)!
|
||||||
|
}
|
||||||
|
setSelectedItem={(val) => {
|
||||||
|
i18n.changeLanguage(val.id);
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
<div className="flex justify-between gap-10 rounded max-md:flex-col">
|
||||||
|
<div className="flex flex-col justify-between">
|
||||||
|
<Slider
|
||||||
|
label="Size"
|
||||||
|
min={14}
|
||||||
|
step={1}
|
||||||
|
max={60}
|
||||||
|
value={captionSettings.style.fontSize}
|
||||||
|
onChange={(e) => setCaptionFontSize(e.target.valueAsNumber)}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label={t("videoPlayer.popouts.captionPreferences.opacity")}
|
||||||
|
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) => (
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex aspect-video h-[200px] flex-col justify-end rounded bg-zinc-800">
|
||||||
|
{selectedCaptionLanguage.id !== "none" ? (
|
||||||
|
<div className="pointer-events-none flex w-full flex-col items-center transition-[bottom]">
|
||||||
|
<CaptionCue
|
||||||
|
scale={0.4}
|
||||||
|
text={selectedCaptionLanguage.nativeName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalCard>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in a new issue