mirror of
https://github.com/movie-web/movie-web.git
synced 2025-01-04 06:35:59 +00:00
subtitle customization
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
parent
f6bbec8907
commit
1491a117b4
|
@ -49,7 +49,7 @@ function SettingsOverlay({ id }: { id: string }) {
|
||||||
<CaptionsView id={id} />
|
<CaptionsView id={id} />
|
||||||
</Context.Card>
|
</Context.Card>
|
||||||
</OverlayPage>
|
</OverlayPage>
|
||||||
<OverlayPage id={id} path="/captions/settings" width={343} height={431}>
|
<OverlayPage id={id} path="/captions/settings" width={343} height={310}>
|
||||||
<Context.Card>
|
<Context.Card>
|
||||||
<CaptionSettingsView id={id} />
|
<CaptionSettingsView id={id} />
|
||||||
</Context.Card>
|
</Context.Card>
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { Context } from "@/components/player/internals/ContextUtils";
|
import { Context } from "@/components/player/internals/ContextUtils";
|
||||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||||
|
import { useProgressBar } from "@/hooks/useProgressBar";
|
||||||
|
import { useSubtitleStore } from "@/stores/subtitles";
|
||||||
|
|
||||||
export function ColorOption(props: {
|
export function ColorOption(props: {
|
||||||
color: string;
|
color: string;
|
||||||
|
@ -29,21 +32,170 @@ export function ColorOption(props: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CaptionSetting(props: {
|
||||||
|
textTransformer?: (s: string) => string;
|
||||||
|
value: number;
|
||||||
|
onChange?: (val: number) => void;
|
||||||
|
max: number;
|
||||||
|
label: string;
|
||||||
|
min: number;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 200 - 100 150 - 100
|
||||||
|
const currentPercentage = (props.value - props.min) / (props.max - props.min);
|
||||||
|
const commit = useCallback(
|
||||||
|
(percentage) => {
|
||||||
|
const range = props.max - props.min;
|
||||||
|
const newPercentage = Math.min(Math.max(percentage, 0), 1);
|
||||||
|
props.onChange?.(props.min + range * newPercentage);
|
||||||
|
},
|
||||||
|
[props]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { dragging, dragPercentage, dragMouseDown } = useProgressBar(
|
||||||
|
ref,
|
||||||
|
commit,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function listener(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter" && isFocused) {
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", listener);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", listener);
|
||||||
|
};
|
||||||
|
}, [isFocused]);
|
||||||
|
|
||||||
|
const inputClasses =
|
||||||
|
"px-3 py-1 bg-video-context-inputBg rounded w-20 text-left text-white cursor-text";
|
||||||
|
const textTransformer = props.textTransformer ?? ((s) => s);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Context.FieldTitle>{props.label}</Context.FieldTitle>
|
||||||
|
<div className="grid items-center grid-cols-[1fr,auto] gap-4">
|
||||||
|
<div ref={ref}>
|
||||||
|
<div
|
||||||
|
className="group/progress w-full h-8 flex items-center cursor-pointer"
|
||||||
|
onMouseDown={dragMouseDown}
|
||||||
|
onTouchStart={dragMouseDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"relative w-full h-1 bg-video-context-slider bg-opacity-25 rounded-full transition-[height] duration-100 group-hover/progress:h-1.5",
|
||||||
|
dragging ? "!h-1.5" : "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{/* Actual progress bar */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 h-full rounded-full bg-video-context-sliderFilled flex justify-end items-center"
|
||||||
|
style={{
|
||||||
|
width: `${
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
1,
|
||||||
|
dragging ? dragPercentage / 100 : currentPercentage
|
||||||
|
)
|
||||||
|
) * 100
|
||||||
|
}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"w-[1rem] min-w-[1rem] h-[1rem] border-[4px] border-video-context-sliderFilled rounded-full transform translate-x-1/2 bg-white transition-[transform] duration-100",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{isFocused ? (
|
||||||
|
<input
|
||||||
|
className={inputClasses}
|
||||||
|
value={inputValue}
|
||||||
|
autoFocus
|
||||||
|
onFocus={(e) => {
|
||||||
|
(e.target as HTMLInputElement).select();
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
setIsFocused(false);
|
||||||
|
const num = Number((e.target as HTMLInputElement).value);
|
||||||
|
if (!Number.isNaN(num)) props.onChange?.(Math.round(num));
|
||||||
|
}}
|
||||||
|
ref={inputRef}
|
||||||
|
onChange={(e) =>
|
||||||
|
setInputValue((e.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={inputClasses}
|
||||||
|
onClick={() => {
|
||||||
|
setInputValue(Math.floor(props.value).toString());
|
||||||
|
setIsFocused(true);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{textTransformer(Math.floor(props.value).toString())}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = ["#ffffff", "#80b1fa", "#e2e535"];
|
||||||
|
|
||||||
export function CaptionSettingsView({ id }: { id: string }) {
|
export function CaptionSettingsView({ id }: { id: string }) {
|
||||||
const router = useOverlayRouter(id);
|
const router = useOverlayRouter(id);
|
||||||
|
const styling = useSubtitleStore((s) => s.styling);
|
||||||
|
const updateStyling = useSubtitleStore((s) => s.updateStyling);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Context.BackLink onClick={() => router.navigate("/captions")}>
|
<Context.BackLink onClick={() => router.navigate("/captions")}>
|
||||||
Custom captions
|
Custom captions
|
||||||
</Context.BackLink>
|
</Context.BackLink>
|
||||||
<Context.Section>
|
<Context.Section className="space-y-6">
|
||||||
|
<CaptionSetting
|
||||||
|
label="Text size"
|
||||||
|
max={200}
|
||||||
|
min={10}
|
||||||
|
textTransformer={(s) => `${s}%`}
|
||||||
|
onChange={(v) => updateStyling({ size: v / 100 })}
|
||||||
|
value={styling.size * 100}
|
||||||
|
/>
|
||||||
|
<CaptionSetting
|
||||||
|
label="Background opacity"
|
||||||
|
max={100}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => updateStyling({ backgroundOpacity: v / 100 })}
|
||||||
|
value={styling.backgroundOpacity * 100}
|
||||||
|
textTransformer={(s) => `${s}%`}
|
||||||
|
/>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<Context.FieldTitle>Color</Context.FieldTitle>
|
<Context.FieldTitle>Color</Context.FieldTitle>
|
||||||
<div className="flex justify-center items-center">
|
<div className="flex justify-center items-center">
|
||||||
<ColorOption onClick={() => {}} color="#FFFFFF" active />
|
{colors.map((v) => (
|
||||||
<ColorOption onClick={() => {}} color="#80B1FA" />
|
<ColorOption
|
||||||
<ColorOption onClick={() => {}} color="#E2E535" />
|
onClick={() => updateStyling({ color: v })}
|
||||||
|
color={v}
|
||||||
|
active={styling.color === v}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Context.Section>
|
</Context.Section>
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { Context } from "@/components/player/internals/ContextUtils";
|
||||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||||
import { Caption } from "@/stores/player/slices/source";
|
import { Caption } from "@/stores/player/slices/source";
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
import { useSubtitleStore } from "@/stores/subtitles";
|
||||||
|
|
||||||
const source: Caption = {
|
const source: Caption = {
|
||||||
language: "nl",
|
language: "nl",
|
||||||
|
@ -51,13 +52,20 @@ export function CaptionsView({ id }: { id: string }) {
|
||||||
const router = useOverlayRouter(id);
|
const router = useOverlayRouter(id);
|
||||||
const setCaption = usePlayerStore((s) => s.setCaption);
|
const setCaption = usePlayerStore((s) => s.setCaption);
|
||||||
const lang = usePlayerStore((s) => s.caption.selected?.language);
|
const lang = usePlayerStore((s) => s.caption.selected?.language);
|
||||||
|
const setLanguage = useSubtitleStore((s) => s.setLanguage);
|
||||||
|
|
||||||
function updateCaption() {
|
function updateCaption(language: string) {
|
||||||
setCaption(source);
|
setCaption({
|
||||||
|
language,
|
||||||
|
srtData: source.srtData,
|
||||||
|
url: source.url,
|
||||||
|
});
|
||||||
|
setLanguage(language);
|
||||||
}
|
}
|
||||||
|
|
||||||
function disableCaption() {
|
function disableCaption() {
|
||||||
setCaption(null);
|
setCaption(null);
|
||||||
|
setLanguage(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const langs = [
|
const langs = [
|
||||||
|
@ -81,13 +89,15 @@ export function CaptionsView({ id }: { id: string }) {
|
||||||
Captions
|
Captions
|
||||||
</Context.BackLink>
|
</Context.BackLink>
|
||||||
<Context.Section>
|
<Context.Section>
|
||||||
<CaptionOption onClick={() => disableCaption()}>Off</CaptionOption>
|
<CaptionOption onClick={() => disableCaption()} selected={!lang}>
|
||||||
|
Off
|
||||||
|
</CaptionOption>
|
||||||
{langs.map((v) => (
|
{langs.map((v) => (
|
||||||
<CaptionOption
|
<CaptionOption
|
||||||
key={v.lang}
|
key={v.lang}
|
||||||
countryCode={v.lang}
|
countryCode={v.lang}
|
||||||
selected={lang === v.lang}
|
selected={lang === v.lang}
|
||||||
onClick={() => updateCaption()}
|
onClick={() => updateCaption(v.lang)}
|
||||||
>
|
>
|
||||||
{v.title}
|
{v.title}
|
||||||
</CaptionOption>
|
</CaptionOption>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { Toggle } from "@/components/buttons/Toggle";
|
import { Toggle } from "@/components/buttons/Toggle";
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
|
@ -6,21 +6,32 @@ import { Context } from "@/components/player/internals/ContextUtils";
|
||||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
import { qualityToString } from "@/stores/player/utils/qualities";
|
import { qualityToString } from "@/stores/player/utils/qualities";
|
||||||
|
import { useSubtitleStore } from "@/stores/subtitles";
|
||||||
import { providers } from "@/utils/providers";
|
import { providers } from "@/utils/providers";
|
||||||
|
|
||||||
export function SettingsMenu({ id }: { id: string }) {
|
export function SettingsMenu({ id }: { id: string }) {
|
||||||
const router = useOverlayRouter(id);
|
const router = useOverlayRouter(id);
|
||||||
const currentQuality = usePlayerStore((s) => s.currentQuality);
|
const currentQuality = usePlayerStore((s) => s.currentQuality);
|
||||||
|
const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage);
|
||||||
|
const selectedCaptionLanguage = usePlayerStore(
|
||||||
|
(s) => s.caption.selected?.language
|
||||||
|
);
|
||||||
|
const subtitlesEnabled = useSubtitleStore((s) => s.enabled);
|
||||||
|
const setSubtitleLanguage = useSubtitleStore((s) => s.setLanguage);
|
||||||
const currentSourceId = usePlayerStore((s) => s.sourceId);
|
const currentSourceId = usePlayerStore((s) => s.sourceId);
|
||||||
|
const setCaption = usePlayerStore((s) => s.setCaption);
|
||||||
const sourceName = useMemo(() => {
|
const sourceName = useMemo(() => {
|
||||||
if (!currentSourceId) return "...";
|
if (!currentSourceId) return "...";
|
||||||
return providers.getMetadata(currentSourceId)?.name ?? "...";
|
return providers.getMetadata(currentSourceId)?.name ?? "...";
|
||||||
}, [currentSourceId]);
|
}, [currentSourceId]);
|
||||||
|
|
||||||
const [tmpBool, setTmpBool] = useState(false);
|
// TODO actually scrape subtitles to load
|
||||||
|
function toggleSubtitles() {
|
||||||
function toggleBool() {
|
if (!subtitlesEnabled) setSubtitleLanguage(lastSelectedLanguage ?? "en");
|
||||||
setTmpBool(!tmpBool);
|
else {
|
||||||
|
setSubtitleLanguage(null);
|
||||||
|
setCaption(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -47,11 +58,16 @@ export function SettingsMenu({ id }: { id: string }) {
|
||||||
<Context.Section>
|
<Context.Section>
|
||||||
<Context.Link>
|
<Context.Link>
|
||||||
<Context.LinkTitle>Enable Captions</Context.LinkTitle>
|
<Context.LinkTitle>Enable Captions</Context.LinkTitle>
|
||||||
<Toggle enabled={tmpBool} onClick={() => toggleBool()} />
|
<Toggle
|
||||||
|
enabled={subtitlesEnabled}
|
||||||
|
onClick={() => toggleSubtitles()}
|
||||||
|
/>
|
||||||
</Context.Link>
|
</Context.Link>
|
||||||
<Context.Link onClick={() => router.navigate("/captions")}>
|
<Context.Link onClick={() => router.navigate("/captions")}>
|
||||||
<Context.LinkTitle>Caption settings</Context.LinkTitle>
|
<Context.LinkTitle>Caption settings</Context.LinkTitle>
|
||||||
<Context.LinkChevron>English</Context.LinkChevron>
|
<Context.LinkChevron>
|
||||||
|
{selectedCaptionLanguage ?? ""}
|
||||||
|
</Context.LinkChevron>
|
||||||
</Context.Link>
|
</Context.Link>
|
||||||
<Context.Link>
|
<Context.Link>
|
||||||
<Context.LinkTitle>Playback settings</Context.LinkTitle>
|
<Context.LinkTitle>Playback settings</Context.LinkTitle>
|
||||||
|
|
|
@ -9,8 +9,15 @@ import {
|
||||||
} from "@/components/player/utils/captions";
|
} from "@/components/player/utils/captions";
|
||||||
import { Transition } from "@/components/Transition";
|
import { Transition } from "@/components/Transition";
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
import { SubtitleStyling, useSubtitleStore } from "@/stores/subtitles";
|
||||||
|
|
||||||
export function CaptionCue({ text }: { text?: string }) {
|
export function CaptionCue({
|
||||||
|
text,
|
||||||
|
styling,
|
||||||
|
}: {
|
||||||
|
text?: string;
|
||||||
|
styling: SubtitleStyling;
|
||||||
|
}) {
|
||||||
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />");
|
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />");
|
||||||
|
|
||||||
// https://www.w3.org/TR/webvtt1/#dom-construction-rules
|
// https://www.w3.org/TR/webvtt1/#dom-construction-rules
|
||||||
|
@ -22,7 +29,14 @@ export function CaptionCue({ text }: { text?: string }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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)]">
|
<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)]"
|
||||||
|
style={{
|
||||||
|
color: styling.color,
|
||||||
|
fontSize: `${(1.5 * styling.size).toFixed(2)}rem`,
|
||||||
|
backgroundColor: `rgba(0,0,0,${styling.backgroundOpacity.toFixed(2)})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
// its sanitised a few lines up
|
// its sanitised a few lines up
|
||||||
// eslint-disable-next-line react/no-danger
|
// eslint-disable-next-line react/no-danger
|
||||||
|
@ -38,6 +52,7 @@ export function CaptionCue({ text }: { text?: string }) {
|
||||||
export function SubtitleRenderer() {
|
export function SubtitleRenderer() {
|
||||||
const videoTime = usePlayerStore((s) => s.progress.time);
|
const videoTime = usePlayerStore((s) => s.progress.time);
|
||||||
const srtData = usePlayerStore((s) => s.caption.selected?.srtData);
|
const srtData = usePlayerStore((s) => s.caption.selected?.srtData);
|
||||||
|
const styling = useSubtitleStore((s) => s.styling);
|
||||||
|
|
||||||
const parsedCaptions = useMemo(
|
const parsedCaptions = useMemo(
|
||||||
() => (srtData ? parseSubtitles(srtData) : []),
|
() => (srtData ? parseSubtitles(srtData) : []),
|
||||||
|
@ -55,7 +70,11 @@ export function SubtitleRenderer() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{visibileCaptions.map(({ start, end, content }, i) => (
|
{visibileCaptions.map(({ start, end, content }, i) => (
|
||||||
<CaptionCue key={makeQueId(i, start, end)} text={content} />
|
<CaptionCue
|
||||||
|
key={makeQueId(i, start, end)}
|
||||||
|
text={content}
|
||||||
|
styling={styling}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
62
src/stores/subtitles/index.ts
Normal file
62
src/stores/subtitles/index.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
|
||||||
|
export interface SubtitleStyling {
|
||||||
|
/**
|
||||||
|
* Text color of subtitles, hex string
|
||||||
|
*/
|
||||||
|
color: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* size percentage, ranges between 0 and 2
|
||||||
|
*/
|
||||||
|
size: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* background opacity, ranges between 0 and 1
|
||||||
|
*/
|
||||||
|
backgroundOpacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleStore {
|
||||||
|
enabled: boolean;
|
||||||
|
lastSelectedLanguage: string | null;
|
||||||
|
styling: SubtitleStyling;
|
||||||
|
updateStyling(newStyling: Partial<SubtitleStyling>): void;
|
||||||
|
setLanguage(language: string | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO add migration from previous stored settings
|
||||||
|
export const useSubtitleStore = create(
|
||||||
|
persist(
|
||||||
|
immer<SubtitleStore>((set) => ({
|
||||||
|
enabled: false,
|
||||||
|
lastSelectedLanguage: null,
|
||||||
|
styling: {
|
||||||
|
color: "#ffffff",
|
||||||
|
backgroundOpacity: 0.5,
|
||||||
|
size: 1,
|
||||||
|
},
|
||||||
|
updateStyling(newStyling) {
|
||||||
|
set((s) => {
|
||||||
|
if (newStyling.backgroundOpacity !== undefined)
|
||||||
|
s.styling.backgroundOpacity = newStyling.backgroundOpacity;
|
||||||
|
if (newStyling.color !== undefined)
|
||||||
|
s.styling.color = newStyling.color.toLowerCase();
|
||||||
|
if (newStyling.size !== undefined)
|
||||||
|
s.styling.size = Math.min(2, Math.max(0.1, newStyling.size));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setLanguage(lang) {
|
||||||
|
set((s) => {
|
||||||
|
s.enabled = !!lang;
|
||||||
|
if (lang) s.lastSelectedLanguage = lang;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
name: "__MW::subtitles",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
|
@ -137,7 +137,10 @@ module.exports = {
|
||||||
border: "#141D23",
|
border: "#141D23",
|
||||||
buttonFocus: "#202836",
|
buttonFocus: "#202836",
|
||||||
flagBg: "#202836",
|
flagBg: "#202836",
|
||||||
|
inputBg: "#202836",
|
||||||
cardBorder: "#1B262E",
|
cardBorder: "#1B262E",
|
||||||
|
slider: "#8787A8",
|
||||||
|
sliderFilled: "#A75FC9",
|
||||||
|
|
||||||
type: {
|
type: {
|
||||||
main: "#617A8A",
|
main: "#617A8A",
|
||||||
|
|
Loading…
Reference in a new issue