mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-20 14:37:43 +01:00
caption keyboard shortcut + searchbar for captions + enabled toggle for keyboard + subtitle padding
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
parent
ca2bab30a4
commit
9ce0e6a099
6 changed files with 179 additions and 88 deletions
|
@ -1,17 +1,15 @@
|
|||
import { ReactNode } from "react";
|
||||
import Fuse from "fuse.js";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { useAsync, useAsyncFn } from "react-use";
|
||||
|
||||
import {
|
||||
downloadSrt,
|
||||
languageIdToName,
|
||||
searchSubtitles,
|
||||
} from "@/backend/helpers/subs";
|
||||
import { languageIdToName } from "@/backend/helpers/subs";
|
||||
import { FlagIcon } from "@/components/FlagIcon";
|
||||
import { useCaptions } from "@/components/player/hooks/useCaptions";
|
||||
import { Menu } from "@/components/player/internals/ContextMenu";
|
||||
import { Input } from "@/components/player/internals/ContextMenu/Input";
|
||||
import { SelectableLink } from "@/components/player/internals/ContextMenu/Links";
|
||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { useSubtitleStore } from "@/stores/subtitles";
|
||||
|
||||
export function CaptionOption(props: {
|
||||
countryCode?: string;
|
||||
|
@ -48,40 +46,22 @@ export function CaptionOption(props: {
|
|||
}
|
||||
|
||||
// TODO cache like everything in this view
|
||||
// TODO make quick settings for caption language
|
||||
// TODO fix language names, some are unknown
|
||||
// TODO add search bar for languages
|
||||
// TODO sort languages by common usage
|
||||
export function CaptionsView({ id }: { id: string }) {
|
||||
const router = useOverlayRouter(id);
|
||||
const setCaption = usePlayerStore((s) => s.setCaption);
|
||||
const lang = usePlayerStore((s) => s.caption.selected?.language);
|
||||
const setLanguage = useSubtitleStore((s) => s.setLanguage);
|
||||
const meta = usePlayerStore((s) => s.meta);
|
||||
const { search, download, disable } = useCaptions();
|
||||
|
||||
const req = useAsync(async () => {
|
||||
if (!meta) throw new Error("No meta");
|
||||
return searchSubtitles(meta);
|
||||
}, [meta]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const req = useAsync(async () => search(), [search]);
|
||||
|
||||
const [downloadReq, startDownload] = useAsyncFn(
|
||||
async (subtitleId: string, language: string) => {
|
||||
const srtData = await downloadSrt(subtitleId);
|
||||
setCaption({
|
||||
language,
|
||||
srtData,
|
||||
url: "", // TODO remove url
|
||||
});
|
||||
setLanguage(language);
|
||||
},
|
||||
[setCaption, setLanguage]
|
||||
(subtitleId: string, language: string) => download(subtitleId, language),
|
||||
[download]
|
||||
);
|
||||
|
||||
function disableCaption() {
|
||||
setCaption(null);
|
||||
setLanguage(null);
|
||||
}
|
||||
|
||||
let downloadProgress: ReactNode = null;
|
||||
if (downloadReq.loading) downloadProgress = <p>downloading...</p>;
|
||||
else if (downloadReq.error) downloadProgress = <p>failed to download...</p>;
|
||||
|
@ -89,19 +69,43 @@ export function CaptionsView({ id }: { id: string }) {
|
|||
let content: ReactNode = null;
|
||||
if (req.loading) content = <p>loading...</p>;
|
||||
else if (req.error) content = <p>errored!</p>;
|
||||
else if (req.value)
|
||||
content = req.value.map((v) => (
|
||||
<CaptionOption
|
||||
key={v.id}
|
||||
countryCode={v.attributes.language}
|
||||
selected={lang === v.attributes.language}
|
||||
onClick={() =>
|
||||
startDownload(v.attributes.legacy_subtitle_id, v.attributes.language)
|
||||
}
|
||||
>
|
||||
{languageIdToName(v.attributes.language) ?? "unknown"}
|
||||
</CaptionOption>
|
||||
));
|
||||
else if (req.value) {
|
||||
const subs = req.value.map((v) => {
|
||||
const languageName = languageIdToName(v.attributes.language) ?? "unknown";
|
||||
return {
|
||||
...v,
|
||||
languageName,
|
||||
};
|
||||
});
|
||||
|
||||
let results = subs;
|
||||
if (searchQuery.trim().length > 0) {
|
||||
const fuse = new Fuse(subs, {
|
||||
includeScore: true,
|
||||
keys: ["languageName"],
|
||||
});
|
||||
|
||||
results = fuse.search(searchQuery).map((res) => res.item);
|
||||
}
|
||||
|
||||
content = results.map((v) => {
|
||||
return (
|
||||
<CaptionOption
|
||||
key={v.id}
|
||||
countryCode={v.attributes.language}
|
||||
selected={lang === v.attributes.language}
|
||||
onClick={() =>
|
||||
startDownload(
|
||||
v.attributes.legacy_subtitle_id,
|
||||
v.attributes.language
|
||||
)
|
||||
}
|
||||
>
|
||||
{v.languageName}
|
||||
</CaptionOption>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -118,9 +122,10 @@ export function CaptionsView({ id }: { id: string }) {
|
|||
>
|
||||
Captions
|
||||
</Menu.BackLink>
|
||||
<Menu.Section>
|
||||
<Menu.Section className="pb-6">
|
||||
<Input value={searchQuery} onInput={setSearchQuery} />
|
||||
{downloadProgress}
|
||||
<CaptionOption onClick={() => disableCaption()} selected={!lang}>
|
||||
<CaptionOption onClick={() => disable()} selected={!lang}>
|
||||
Off
|
||||
</CaptionOption>
|
||||
{content}
|
||||
|
|
|
@ -3,6 +3,7 @@ import { useMemo } from "react";
|
|||
import { languageIdToName } from "@/backend/helpers/subs";
|
||||
import { Toggle } from "@/components/buttons/Toggle";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useCaptions } from "@/components/player/hooks/useCaptions";
|
||||
import { Menu } from "@/components/player/internals/ContextMenu";
|
||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
|
@ -13,27 +14,16 @@ import { providers } from "@/utils/providers";
|
|||
export function SettingsMenu({ id }: { id: string }) {
|
||||
const router = useOverlayRouter(id);
|
||||
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 setCaption = usePlayerStore((s) => s.setCaption);
|
||||
const sourceName = useMemo(() => {
|
||||
if (!currentSourceId) return "...";
|
||||
return providers.getMetadata(currentSourceId)?.name ?? "...";
|
||||
}, [currentSourceId]);
|
||||
|
||||
// TODO actually scrape subtitles to load
|
||||
function toggleSubtitles() {
|
||||
if (!subtitlesEnabled) setSubtitleLanguage(lastSelectedLanguage ?? "en");
|
||||
else {
|
||||
setSubtitleLanguage(null);
|
||||
setCaption(null);
|
||||
}
|
||||
}
|
||||
const { toggleLastUsed } = useCaptions();
|
||||
|
||||
const selectedLanguagePretty = selectedCaptionLanguage
|
||||
? languageIdToName(selectedCaptionLanguage) ?? "unknown"
|
||||
|
@ -77,7 +67,7 @@ export function SettingsMenu({ id }: { id: string }) {
|
|||
rightSide={
|
||||
<Toggle
|
||||
enabled={subtitlesEnabled}
|
||||
onClick={() => toggleSubtitles()}
|
||||
onClick={() => toggleLastUsed().catch(() => {})}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
63
src/components/player/hooks/useCaptions.ts
Normal file
63
src/components/player/hooks/useCaptions.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { useCallback } from "react";
|
||||
|
||||
import { downloadSrt, searchSubtitles } from "@/backend/helpers/subs";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { useSubtitleStore } from "@/stores/subtitles";
|
||||
|
||||
export function useCaptions() {
|
||||
const setLanguage = useSubtitleStore((s) => s.setLanguage);
|
||||
const enabled = useSubtitleStore((s) => s.enabled);
|
||||
const setCaption = usePlayerStore((s) => s.setCaption);
|
||||
const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage);
|
||||
const meta = usePlayerStore((s) => s.meta);
|
||||
|
||||
const download = useCallback(
|
||||
async (subtitleId: string, language: string) => {
|
||||
const srtData = await downloadSrt(subtitleId);
|
||||
setCaption({
|
||||
language,
|
||||
srtData,
|
||||
url: "", // TODO remove url
|
||||
});
|
||||
setLanguage(language);
|
||||
},
|
||||
[setCaption, setLanguage]
|
||||
);
|
||||
|
||||
const search = useCallback(async () => {
|
||||
if (!meta) throw new Error("No meta");
|
||||
return searchSubtitles(meta);
|
||||
}, [meta]);
|
||||
|
||||
const disable = useCallback(async () => {
|
||||
setCaption(null);
|
||||
setLanguage(null);
|
||||
}, [setCaption, setLanguage]);
|
||||
|
||||
const downloadLastUsed = useCallback(async () => {
|
||||
const language = lastSelectedLanguage ?? "en";
|
||||
const searchResult = await search();
|
||||
const languageResult = searchResult.find(
|
||||
(v) => v.attributes.language === language
|
||||
);
|
||||
if (!languageResult) return false;
|
||||
await download(
|
||||
languageResult.attributes.legacy_subtitle_id,
|
||||
languageResult.attributes.language
|
||||
);
|
||||
return true;
|
||||
}, [lastSelectedLanguage, search, download]);
|
||||
|
||||
const toggleLastUsed = useCallback(async () => {
|
||||
if (!enabled) await downloadLastUsed();
|
||||
else disable();
|
||||
}, [downloadLastUsed, disable, enabled]);
|
||||
|
||||
return {
|
||||
download,
|
||||
search,
|
||||
disable,
|
||||
downloadLastUsed,
|
||||
toggleLastUsed,
|
||||
};
|
||||
}
|
21
src/components/player/internals/ContextMenu/Input.tsx
Normal file
21
src/components/player/internals/ContextMenu/Input.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
export function Input(props: {
|
||||
value: string;
|
||||
onInput: (str: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full relative mb-6">
|
||||
<Icon
|
||||
className="pointer-events-none absolute top-1/2 left-3 transform -translate-y-1/2 text-video-context-inputPlaceholder"
|
||||
icon={Icons.SEARCH}
|
||||
/>
|
||||
<input
|
||||
placeholder="Search"
|
||||
className="w-full py-2 px-3 pl-[calc(0.75rem+24px)] bg-video-context-inputBg rounded placeholder:text-video-context-inputPlaceholder"
|
||||
value={props.value}
|
||||
onInput={(e) => props.onInput(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useCaptions } from "@/components/player/hooks/useCaptions";
|
||||
import { useVolume } from "@/components/player/hooks/useVolume";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { useEmpheralVolumeStore } from "@/stores/volume";
|
||||
|
@ -10,6 +11,7 @@ export function KeyboardEvents() {
|
|||
const time = usePlayerStore((s) => s.progress.time);
|
||||
const { setVolume, toggleMute } = useVolume();
|
||||
|
||||
const { toggleLastUsed } = useCaptions();
|
||||
const setShowVolume = useEmpheralVolumeStore((s) => s.setShowVolume);
|
||||
|
||||
const [isRolling, setIsRolling] = useState(false);
|
||||
|
@ -20,6 +22,7 @@ export function KeyboardEvents() {
|
|||
setVolume,
|
||||
toggleMute,
|
||||
setIsRolling,
|
||||
toggleLastUsed,
|
||||
display,
|
||||
mediaPlaying,
|
||||
isRolling,
|
||||
|
@ -31,6 +34,7 @@ export function KeyboardEvents() {
|
|||
setVolume,
|
||||
toggleMute,
|
||||
setIsRolling,
|
||||
toggleLastUsed,
|
||||
display,
|
||||
mediaPlaying,
|
||||
isRolling,
|
||||
|
@ -41,6 +45,7 @@ export function KeyboardEvents() {
|
|||
setVolume,
|
||||
toggleMute,
|
||||
setIsRolling,
|
||||
toggleLastUsed,
|
||||
display,
|
||||
mediaPlaying,
|
||||
isRolling,
|
||||
|
@ -49,6 +54,9 @@ export function KeyboardEvents() {
|
|||
|
||||
useEffect(() => {
|
||||
const keyEventHandler = (evt: KeyboardEvent) => {
|
||||
if (evt.target && (evt.target as HTMLInputElement).nodeName === "INPUT")
|
||||
return;
|
||||
|
||||
const k = evt.key;
|
||||
|
||||
// Volume
|
||||
|
@ -83,6 +91,9 @@ export function KeyboardEvents() {
|
|||
dataRef.current.mediaPlaying.isPaused ? "play" : "pause"
|
||||
]();
|
||||
|
||||
// captions
|
||||
if (k === "c") dataRef.current.toggleLastUsed().catch(() => {}); // ignore errors
|
||||
|
||||
// Do a barrell roll!
|
||||
if (k === "r") {
|
||||
if (dataRef.current.isRolling || evt.ctrlKey || evt.metaKey) return;
|
||||
|
|
|
@ -26,23 +26,23 @@ module.exports = {
|
|||
"ash-400": "#3D394D",
|
||||
"ash-300": "#2C293A",
|
||||
"ash-200": "#2B2836",
|
||||
"ash-100": "#1E1C26",
|
||||
"ash-100": "#1E1C26"
|
||||
},
|
||||
|
||||
/* fonts */
|
||||
fontFamily: {
|
||||
"open-sans": "'Open Sans'",
|
||||
"open-sans": "'Open Sans'"
|
||||
},
|
||||
|
||||
/* animations */
|
||||
keyframes: {
|
||||
"loading-pin": {
|
||||
"0%, 40%, 100%": { height: "0.5em", "background-color": "#282336" },
|
||||
"20%": { height: "1em", "background-color": "white" },
|
||||
},
|
||||
"20%": { height: "1em", "background-color": "white" }
|
||||
}
|
||||
},
|
||||
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" },
|
||||
},
|
||||
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" }
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
require("tailwind-scrollbar"),
|
||||
|
@ -52,31 +52,31 @@ module.exports = {
|
|||
colors: {
|
||||
// Branding
|
||||
pill: {
|
||||
background: "#1C1C36",
|
||||
background: "#1C1C36"
|
||||
},
|
||||
|
||||
// meta data for the theme itself
|
||||
global: {
|
||||
accentA: "#505DBD",
|
||||
accentB: "#3440A1",
|
||||
accentB: "#3440A1"
|
||||
},
|
||||
|
||||
// light bar
|
||||
lightBar: {
|
||||
light: "#2A2A71",
|
||||
light: "#2A2A71"
|
||||
},
|
||||
|
||||
// Buttons
|
||||
buttons: {
|
||||
toggle: "#8D44D6",
|
||||
toggleDisabled: "#202836",
|
||||
toggleDisabled: "#202836"
|
||||
},
|
||||
|
||||
// only used for body colors/textures
|
||||
background: {
|
||||
main: "#0A0A10",
|
||||
accentA: "#6E3B80",
|
||||
accentB: "#1F1F50",
|
||||
accentB: "#1F1F50"
|
||||
},
|
||||
|
||||
// typography
|
||||
|
@ -85,7 +85,7 @@ module.exports = {
|
|||
text: "#73739D",
|
||||
dimmed: "#926CAD",
|
||||
divider: "#262632",
|
||||
secondary: "#64647B",
|
||||
secondary: "#64647B"
|
||||
},
|
||||
|
||||
// search bar
|
||||
|
@ -94,7 +94,7 @@ module.exports = {
|
|||
focused: "#24243C",
|
||||
placeholder: "#4A4A71",
|
||||
icon: "#545476",
|
||||
text: "#FFFFFF",
|
||||
text: "#FFFFFF"
|
||||
},
|
||||
|
||||
// media cards
|
||||
|
@ -106,7 +106,7 @@ module.exports = {
|
|||
barColor: "#4B4B63",
|
||||
barFillColor: "#BA7FD6",
|
||||
badge: "#151522",
|
||||
badgeText: "#5F5F7A",
|
||||
badgeText: "#5F5F7A"
|
||||
},
|
||||
|
||||
// video player
|
||||
|
@ -118,17 +118,17 @@ module.exports = {
|
|||
error: "#E44F4F",
|
||||
success: "#40B44B",
|
||||
loading: "#B759D8",
|
||||
noresult: "#64647B",
|
||||
noresult: "#64647B"
|
||||
},
|
||||
|
||||
progress: {
|
||||
background: "#8787A8",
|
||||
preloaded: "#8787A8",
|
||||
watched: "#A75FC9",
|
||||
watched: "#A75FC9"
|
||||
},
|
||||
|
||||
audio: {
|
||||
set: "#A75FC9",
|
||||
set: "#A75FC9"
|
||||
},
|
||||
|
||||
buttons: {
|
||||
|
@ -137,7 +137,7 @@ module.exports = {
|
|||
secondaryHover: "#1B262E",
|
||||
primary: "#fff",
|
||||
primaryText: "#000",
|
||||
primaryHover: "#dedede",
|
||||
primaryHover: "#dedede"
|
||||
},
|
||||
|
||||
context: {
|
||||
|
@ -148,30 +148,31 @@ module.exports = {
|
|||
buttonFocus: "#202836",
|
||||
flagBg: "#202836",
|
||||
inputBg: "#202836",
|
||||
inputPlaceholder: "#374A56",
|
||||
cardBorder: "#1B262E",
|
||||
slider: "#8787A8",
|
||||
sliderFilled: "#A75FC9",
|
||||
|
||||
download: {
|
||||
button: "#6b298a",
|
||||
hover: "#7f35a1",
|
||||
hover: "#7f35a1"
|
||||
},
|
||||
|
||||
buttons: {
|
||||
list: "#161C26",
|
||||
active: "#0D1317",
|
||||
active: "#0D1317"
|
||||
},
|
||||
|
||||
type: {
|
||||
main: "#617A8A",
|
||||
secondary: "#374A56",
|
||||
accent: "#A570FA",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
accent: "#A570FA"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue