mirror of
https://github.com/sussy-code/smov.git
synced 2025-01-01 16:37:39 +01:00
Merge pull request #69 from ztpn/subs-icon
Add a shortcut to subtitle settings in the bottomControls and update ui
This commit is contained in:
commit
1e79f649f4
11 changed files with 435 additions and 295 deletions
|
@ -372,6 +372,7 @@
|
||||||
"customChoice": "Drop or upload file",
|
"customChoice": "Drop or upload file",
|
||||||
"customizeLabel": "Customize",
|
"customizeLabel": "Customize",
|
||||||
"offChoice": "Off",
|
"offChoice": "Off",
|
||||||
|
"SourceChoice": "Source Captions",
|
||||||
"OpenSubtitlesChoice": "OpenSubtitles",
|
"OpenSubtitlesChoice": "OpenSubtitles",
|
||||||
"settings": {
|
"settings": {
|
||||||
"backlink": "Custom subtitles",
|
"backlink": "Custom subtitles",
|
||||||
|
@ -382,7 +383,8 @@
|
||||||
"unknownLanguage": "Unknown",
|
"unknownLanguage": "Unknown",
|
||||||
"dropSubtitleFile": "Drop subtitle file here! >_<",
|
"dropSubtitleFile": "Drop subtitle file here! >_<",
|
||||||
"scrapeButton": "Scrape subtitles",
|
"scrapeButton": "Scrape subtitles",
|
||||||
"empty": "There are no provided subtitles for this."
|
"empty": "There are no provided subtitles for this.",
|
||||||
|
"notFound": "None of the available options match your query"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
|
|
28
src/components/player/atoms/Captions.tsx
Normal file
28
src/components/player/atoms/Captions.tsx
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { Icons } from "@/components/Icon";
|
||||||
|
import { OverlayAnchor } from "@/components/overlays/OverlayAnchor";
|
||||||
|
import { VideoPlayerButton } from "@/components/player/internals/Button";
|
||||||
|
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||||
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
|
||||||
|
export function Captions() {
|
||||||
|
const router = useOverlayRouter("settings");
|
||||||
|
const setHasOpenOverlay = usePlayerStore((s) => s.setHasOpenOverlay);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasOpenOverlay(router.isRouterActive);
|
||||||
|
}, [setHasOpenOverlay, router.isRouterActive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OverlayAnchor id={router.id}>
|
||||||
|
<VideoPlayerButton
|
||||||
|
onClick={() => {
|
||||||
|
router.open();
|
||||||
|
router.navigate("/captionsOverlay");
|
||||||
|
}}
|
||||||
|
icon={Icons.CAPTIONS}
|
||||||
|
/>
|
||||||
|
</OverlayAnchor>
|
||||||
|
);
|
||||||
|
}
|
|
@ -18,10 +18,11 @@ import { AudioView } from "./settings/AudioView";
|
||||||
import { CaptionSettingsView } from "./settings/CaptionSettingsView";
|
import { CaptionSettingsView } from "./settings/CaptionSettingsView";
|
||||||
import { CaptionsView } from "./settings/CaptionsView";
|
import { CaptionsView } from "./settings/CaptionsView";
|
||||||
import { DownloadRoutes } from "./settings/Downloads";
|
import { DownloadRoutes } from "./settings/Downloads";
|
||||||
import { OpenSubtitlesCaptionView } from "./settings/Opensubtitles";
|
import { OpenSubtitlesCaptionView } from "./settings/OpensubtitlesCaptionsView";
|
||||||
import { PlaybackSettingsView } from "./settings/PlaybackSettingsView";
|
import { PlaybackSettingsView } from "./settings/PlaybackSettingsView";
|
||||||
import { QualityView } from "./settings/QualityView";
|
import { QualityView } from "./settings/QualityView";
|
||||||
import { SettingsMenu } from "./settings/SettingsMenu";
|
import { SettingsMenu } from "./settings/SettingsMenu";
|
||||||
|
import SourceCaptionsView from "./settings/SourceCaptionsView";
|
||||||
|
|
||||||
function SettingsOverlay({ id }: { id: string }) {
|
function SettingsOverlay({ id }: { id: string }) {
|
||||||
const [chosenSourceId, setChosenSourceId] = useState<string | null>(null);
|
const [chosenSourceId, setChosenSourceId] = useState<string | null>(null);
|
||||||
|
@ -54,6 +55,12 @@ function SettingsOverlay({ id }: { id: string }) {
|
||||||
</Menu.Card>
|
</Menu.Card>
|
||||||
</OverlayPage>
|
</OverlayPage>
|
||||||
<OverlayPage id={id} path="/captions" width={343} height={431}>
|
<OverlayPage id={id} path="/captions" width={343} height={431}>
|
||||||
|
<Menu.CardWithScrollable>
|
||||||
|
<CaptionsView id={id} backLink />
|
||||||
|
</Menu.CardWithScrollable>
|
||||||
|
</OverlayPage>
|
||||||
|
{/* This is used by the captions shortcut in bottomControls of player */}
|
||||||
|
<OverlayPage id={id} path="/captionsOverlay" width={343} height={431}>
|
||||||
<Menu.CardWithScrollable>
|
<Menu.CardWithScrollable>
|
||||||
<CaptionsView id={id} />
|
<CaptionsView id={id} />
|
||||||
</Menu.CardWithScrollable>
|
</Menu.CardWithScrollable>
|
||||||
|
@ -68,11 +75,49 @@ function SettingsOverlay({ id }: { id: string }) {
|
||||||
<OpenSubtitlesCaptionView id={id} />
|
<OpenSubtitlesCaptionView id={id} />
|
||||||
</Menu.Card>
|
</Menu.Card>
|
||||||
</OverlayPage>
|
</OverlayPage>
|
||||||
|
{/* This is used by the captions shortcut in bottomControls of player */}
|
||||||
|
<OverlayPage
|
||||||
|
id={id}
|
||||||
|
path="/captions/opensubtitlesOverlay"
|
||||||
|
width={343}
|
||||||
|
height={431}
|
||||||
|
>
|
||||||
|
<Menu.Card>
|
||||||
|
<OpenSubtitlesCaptionView id={id} overlayBackLink />
|
||||||
|
</Menu.Card>
|
||||||
|
</OverlayPage>
|
||||||
|
<OverlayPage id={id} path="/captions/source" width={343} height={431}>
|
||||||
|
<Menu.Card>
|
||||||
|
<SourceCaptionsView id={id} />
|
||||||
|
</Menu.Card>
|
||||||
|
</OverlayPage>
|
||||||
|
{/* This is used by the captions shortcut in bottomControls of player */}
|
||||||
|
<OverlayPage
|
||||||
|
id={id}
|
||||||
|
path="/captions/sourceOverlay"
|
||||||
|
width={343}
|
||||||
|
height={431}
|
||||||
|
>
|
||||||
|
<Menu.Card>
|
||||||
|
<SourceCaptionsView id={id} overlayBackLink />
|
||||||
|
</Menu.Card>
|
||||||
|
</OverlayPage>
|
||||||
<OverlayPage id={id} path="/captions/settings" width={343} height={450}>
|
<OverlayPage id={id} path="/captions/settings" width={343} height={450}>
|
||||||
<Menu.Card>
|
<Menu.Card>
|
||||||
<CaptionSettingsView id={id} />
|
<CaptionSettingsView id={id} />
|
||||||
</Menu.Card>
|
</Menu.Card>
|
||||||
</OverlayPage>
|
</OverlayPage>
|
||||||
|
{/* This is used by the captions shortcut in bottomControls of player */}
|
||||||
|
<OverlayPage
|
||||||
|
id={id}
|
||||||
|
path="/captions/settingsOverlay"
|
||||||
|
width={343}
|
||||||
|
height={450}
|
||||||
|
>
|
||||||
|
<Menu.Card>
|
||||||
|
<CaptionSettingsView id={id} overlayBackLink />
|
||||||
|
</Menu.Card>
|
||||||
|
</OverlayPage>
|
||||||
<OverlayPage id={id} path="/source" width={343} height={431}>
|
<OverlayPage id={id} path="/source" width={343} height={431}>
|
||||||
<Menu.CardWithScrollable>
|
<Menu.CardWithScrollable>
|
||||||
<SourceSelectionView id={id} onChoose={setChosenSourceId} />
|
<SourceSelectionView id={id} onChoose={setChosenSourceId} />
|
||||||
|
|
|
@ -16,3 +16,4 @@ export * from "./VolumeChangedPopout";
|
||||||
export * from "./NextEpisodeButton";
|
export * from "./NextEpisodeButton";
|
||||||
export * from "./Chromecast";
|
export * from "./Chromecast";
|
||||||
export * from "./CastingNotification";
|
export * from "./CastingNotification";
|
||||||
|
export * from "./Captions";
|
||||||
|
|
|
@ -216,7 +216,13 @@ export function CaptionSetting(props: {
|
||||||
|
|
||||||
export const colors = ["#ffffff", "#b0b0b0", "#80b1fa", "#e2e535"];
|
export const colors = ["#ffffff", "#b0b0b0", "#80b1fa", "#e2e535"];
|
||||||
|
|
||||||
export function CaptionSettingsView({ id }: { id: string }) {
|
export function CaptionSettingsView({
|
||||||
|
id,
|
||||||
|
overlayBackLink,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
overlayBackLink?: boolean;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useOverlayRouter(id);
|
const router = useOverlayRouter(id);
|
||||||
const styling = useSubtitleStore((s) => s.styling);
|
const styling = useSubtitleStore((s) => s.styling);
|
||||||
|
@ -228,7 +234,11 @@ export function CaptionSettingsView({ id }: { id: string }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu.BackLink onClick={() => router.navigate("/captions")}>
|
<Menu.BackLink
|
||||||
|
onClick={() =>
|
||||||
|
router.navigate(overlayBackLink ? "/captionsOverlay" : "/captions")
|
||||||
|
}
|
||||||
|
>
|
||||||
{t("player.menus.subtitles.settings.backlink")}
|
{t("player.menus.subtitles.settings.backlink")}
|
||||||
</Menu.BackLink>
|
</Menu.BackLink>
|
||||||
<Menu.Section className="space-y-6 pb-5">
|
<Menu.Section className="space-y-6 pb-5">
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import Fuse from "fuse.js";
|
import { type DragEvent, useRef, useState } from "react";
|
||||||
import { type DragEvent, useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAsyncFn } from "react-use";
|
|
||||||
import { convert } from "subsrt-ts";
|
import { convert } from "subsrt-ts";
|
||||||
|
|
||||||
import { subtitleTypeList } from "@/backend/helpers/subs";
|
import { subtitleTypeList } from "@/backend/helpers/subs";
|
||||||
|
@ -11,16 +9,11 @@ import { FlagIcon } from "@/components/FlagIcon";
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { useCaptions } from "@/components/player/hooks/useCaptions";
|
import { useCaptions } from "@/components/player/hooks/useCaptions";
|
||||||
import { Menu } from "@/components/player/internals/ContextMenu";
|
import { Menu } from "@/components/player/internals/ContextMenu";
|
||||||
import { Input } from "@/components/player/internals/ContextMenu/Input";
|
|
||||||
import { SelectableLink } from "@/components/player/internals/ContextMenu/Links";
|
import { SelectableLink } from "@/components/player/internals/ContextMenu/Links";
|
||||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||||
import { CaptionListItem } from "@/stores/player/slices/source";
|
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
import { useSubtitleStore } from "@/stores/subtitles";
|
import { useSubtitleStore } from "@/stores/subtitles";
|
||||||
import {
|
import { getPrettyLanguageNameFromLocale } from "@/utils/language";
|
||||||
getPrettyLanguageNameFromLocale,
|
|
||||||
sortLangCodes,
|
|
||||||
} from "@/utils/language";
|
|
||||||
|
|
||||||
export function CaptionOption(props: {
|
export function CaptionOption(props: {
|
||||||
countryCode?: string;
|
countryCode?: string;
|
||||||
|
@ -29,7 +22,6 @@ export function CaptionOption(props: {
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
error?: React.ReactNode;
|
error?: React.ReactNode;
|
||||||
chevron?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectableLink
|
<SelectableLink
|
||||||
|
@ -37,7 +29,6 @@ export function CaptionOption(props: {
|
||||||
loading={props.loading}
|
loading={props.loading}
|
||||||
error={props.error}
|
error={props.error}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
chevron={props.chevron}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
data-active-link={props.selected ? true : undefined}
|
data-active-link={props.selected ? true : undefined}
|
||||||
|
@ -52,7 +43,7 @@ export function CaptionOption(props: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomCaptionOption() {
|
export function CustomCaptionOption() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const lang = usePlayerStore((s) => s.caption.selected?.language);
|
const lang = usePlayerStore((s) => s.caption.selected?.language);
|
||||||
const setCaption = usePlayerStore((s) => s.setCaption);
|
const setCaption = usePlayerStore((s) => s.setCaption);
|
||||||
|
@ -91,47 +82,22 @@ function CustomCaptionOption() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSubtitleList(subs: CaptionListItem[], searchQuery: string) {
|
export function CaptionsView({
|
||||||
const { t: translate } = useTranslation();
|
id,
|
||||||
const unknownChoice = translate("player.menus.subtitles.unknownLanguage");
|
backLink,
|
||||||
return useMemo(() => {
|
}: {
|
||||||
const input = subs
|
id: string;
|
||||||
.map((t) => ({
|
backLink?: true;
|
||||||
...t,
|
}) {
|
||||||
languageName:
|
|
||||||
getPrettyLanguageNameFromLocale(t.language) ?? unknownChoice,
|
|
||||||
}))
|
|
||||||
.filter((x) => !x.opensubtitles);
|
|
||||||
const sorted = sortLangCodes(input.map((t) => t.language));
|
|
||||||
let results = input.sort((a, b) => {
|
|
||||||
return sorted.indexOf(a.language) - sorted.indexOf(b.language);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (searchQuery.trim().length > 0) {
|
|
||||||
const fuse = new Fuse(input, {
|
|
||||||
includeScore: true,
|
|
||||||
keys: ["languageName"],
|
|
||||||
});
|
|
||||||
|
|
||||||
results = fuse.search(searchQuery).map((res) => res.item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}, [subs, searchQuery, unknownChoice]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CaptionsView({ id }: { id: string }) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useOverlayRouter(id);
|
const router = useOverlayRouter(id);
|
||||||
const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id);
|
const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id);
|
||||||
const [currentlyDownloading, setCurrentlyDownloading] = useState<
|
const { disable } = useCaptions();
|
||||||
string | null
|
|
||||||
>(null);
|
|
||||||
const { selectCaptionById, disable } = useCaptions();
|
|
||||||
const captionList = usePlayerStore((s) => s.captionList);
|
|
||||||
const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList);
|
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const setCaption = usePlayerStore((s) => s.setCaption);
|
const setCaption = usePlayerStore((s) => s.setCaption);
|
||||||
|
const selectedCaptionLanguage = usePlayerStore(
|
||||||
|
(s) => s.caption.selected?.language,
|
||||||
|
);
|
||||||
|
|
||||||
function onDrop(event: DragEvent<HTMLDivElement>) {
|
function onDrop(event: DragEvent<HTMLDivElement>) {
|
||||||
const files = event.dataTransfer.files;
|
const files = event.dataTransfer.files;
|
||||||
|
@ -159,42 +125,10 @@ export function CaptionsView({ id }: { id: string }) {
|
||||||
reader.readAsText(firstFile);
|
reader.readAsText(firstFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
const captions = useMemo(
|
const selectedLanguagePretty = selectedCaptionLanguage
|
||||||
() =>
|
? getPrettyLanguageNameFromLocale(selectedCaptionLanguage) ??
|
||||||
captionList.length !== 0 ? captionList : getHlsCaptionList?.() ?? [],
|
t("player.menus.subtitles.unknownLanguage")
|
||||||
[captionList, getHlsCaptionList],
|
: undefined;
|
||||||
);
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const subtitleList = useSubtitleList(captions, searchQuery);
|
|
||||||
|
|
||||||
const [downloadReq, startDownload] = useAsyncFn(
|
|
||||||
async (captionId: string) => {
|
|
||||||
setCurrentlyDownloading(captionId);
|
|
||||||
return selectCaptionById(captionId);
|
|
||||||
},
|
|
||||||
[selectCaptionById, setCurrentlyDownloading],
|
|
||||||
);
|
|
||||||
|
|
||||||
const content = subtitleList.map((v) => {
|
|
||||||
return (
|
|
||||||
<CaptionOption
|
|
||||||
// key must use index to prevent url collisions
|
|
||||||
key={v.id}
|
|
||||||
countryCode={v.language}
|
|
||||||
selected={v.id === selectedCaptionId}
|
|
||||||
loading={v.id === currentlyDownloading && downloadReq.loading}
|
|
||||||
error={
|
|
||||||
v.id === currentlyDownloading && downloadReq.error
|
|
||||||
? downloadReq.error.toString()
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onClick={() => startDownload(v.id)}
|
|
||||||
>
|
|
||||||
{v.languageName}
|
|
||||||
</CaptionOption>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -213,6 +147,7 @@ export function CaptionsView({ id }: { id: string }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{backLink ? (
|
||||||
<Menu.BackLink
|
<Menu.BackLink
|
||||||
onClick={() => router.navigate("/")}
|
onClick={() => router.navigate("/")}
|
||||||
rightSide={
|
rightSide={
|
||||||
|
@ -227,6 +162,21 @@ export function CaptionsView({ id }: { id: string }) {
|
||||||
>
|
>
|
||||||
{t("player.menus.subtitles.title")}
|
{t("player.menus.subtitles.title")}
|
||||||
</Menu.BackLink>
|
</Menu.BackLink>
|
||||||
|
) : (
|
||||||
|
<Menu.Title
|
||||||
|
rightSide={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.navigate("/captions/settingsOverlay")}
|
||||||
|
className="-mr-2 -my-1 px-2 p-[0.4em] rounded tabbable hover:bg-video-context-light hover:bg-opacity-10"
|
||||||
|
>
|
||||||
|
{t("player.menus.subtitles.customizeLabel")}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("player.menus.subtitles.title")}
|
||||||
|
</Menu.Title>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<FileDropHandler
|
<FileDropHandler
|
||||||
className={`transition duration-300 ${dragging ? "opacity-20" : ""}`}
|
className={`transition duration-300 ${dragging ? "opacity-20" : ""}`}
|
||||||
|
@ -235,16 +185,6 @@ export function CaptionsView({ id }: { id: string }) {
|
||||||
}}
|
}}
|
||||||
onDrop={(event) => onDrop(event)}
|
onDrop={(event) => onDrop(event)}
|
||||||
>
|
>
|
||||||
<div className="mt-3 flex flex-row gap-2">
|
|
||||||
<Input value={searchQuery} onInput={setSearchQuery} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.navigate("/captions/opensubtitles")}
|
|
||||||
className="p-[0.5em] rounded tabbable hover:bg-video-context-hoverColor hover:bg-opacity-50"
|
|
||||||
>
|
|
||||||
<Icon icon={Icons.WEB} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3">
|
<Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3">
|
||||||
<CaptionOption
|
<CaptionOption
|
||||||
onClick={() => disable()}
|
onClick={() => disable()}
|
||||||
|
@ -253,22 +193,36 @@ export function CaptionsView({ id }: { id: string }) {
|
||||||
{t("player.menus.subtitles.offChoice")}
|
{t("player.menus.subtitles.offChoice")}
|
||||||
</CaptionOption>
|
</CaptionOption>
|
||||||
<CustomCaptionOption />
|
<CustomCaptionOption />
|
||||||
{content.length === 0 ? (
|
<Menu.ChevronLink
|
||||||
<div className="p-4 rounded-xl bg-video-context-light bg-opacity-10 font-medium text-center">
|
onClick={() =>
|
||||||
<div className="flex flex-col items-center justify-center gap-3">
|
router.navigate(
|
||||||
{t("player.menus.subtitles.empty")}
|
backLink ? "/captions/source" : "/captions/sourceOverlay",
|
||||||
<button
|
)
|
||||||
type="button"
|
}
|
||||||
onClick={() => router.navigate("/captions/opensubtitles")}
|
rightText={
|
||||||
className="p-1 w-3/4 rounded tabbable duration-200 bg-opacity-10 bg-video-context-light hover:bg-opacity-20"
|
useSubtitleStore((s) => s.isOpenSubtitles)
|
||||||
|
? ""
|
||||||
|
: selectedLanguagePretty
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t("player.menus.subtitles.scrapeButton")}
|
{t("player.menus.subtitles.SourceChoice")}
|
||||||
</button>
|
</Menu.ChevronLink>
|
||||||
</div>
|
<Menu.ChevronLink
|
||||||
</div>
|
onClick={() =>
|
||||||
) : (
|
router.navigate(
|
||||||
content
|
backLink
|
||||||
)}
|
? "/captions/opensubtitles"
|
||||||
|
: "/captions/opensubtitlesOverlay",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rightText={
|
||||||
|
useSubtitleStore((s) => s.isOpenSubtitles)
|
||||||
|
? selectedLanguagePretty
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("player.menus.subtitles.OpenSubtitlesChoice")}
|
||||||
|
</Menu.ChevronLink>
|
||||||
</Menu.ScrollToActiveSection>
|
</Menu.ScrollToActiveSection>
|
||||||
</FileDropHandler>
|
</FileDropHandler>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -1,141 +0,0 @@
|
||||||
import Fuse from "fuse.js";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useAsyncFn } from "react-use";
|
|
||||||
|
|
||||||
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 { CaptionListItem } from "@/stores/player/slices/source";
|
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
|
||||||
import {
|
|
||||||
getPrettyLanguageNameFromLocale,
|
|
||||||
sortLangCodes,
|
|
||||||
} from "@/utils/language";
|
|
||||||
|
|
||||||
export function CaptionOption(props: {
|
|
||||||
countryCode?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
selected?: boolean;
|
|
||||||
loading?: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
error?: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SelectableLink
|
|
||||||
selected={props.selected}
|
|
||||||
loading={props.loading}
|
|
||||||
error={props.error}
|
|
||||||
onClick={props.onClick}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
data-active-link={props.selected ? true : undefined}
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
<span data-code={props.countryCode} className="mr-3 inline-flex">
|
|
||||||
<FlagIcon langCode={props.countryCode} />
|
|
||||||
</span>
|
|
||||||
<span>{props.children}</span>
|
|
||||||
</span>
|
|
||||||
</SelectableLink>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useSubtitleList(subs: CaptionListItem[], searchQuery: string) {
|
|
||||||
const { t: translate } = useTranslation();
|
|
||||||
const unknownChoice = translate("player.menus.subtitles.unknownLanguage");
|
|
||||||
return useMemo(() => {
|
|
||||||
const input = subs
|
|
||||||
.map((t) => ({
|
|
||||||
...t,
|
|
||||||
languageName:
|
|
||||||
getPrettyLanguageNameFromLocale(t.language) ?? unknownChoice,
|
|
||||||
}))
|
|
||||||
.filter((x) => x.opensubtitles);
|
|
||||||
const sorted = sortLangCodes(input.map((t) => t.language));
|
|
||||||
let results = input.sort((a, b) => {
|
|
||||||
return sorted.indexOf(a.language) - sorted.indexOf(b.language);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (searchQuery.trim().length > 0) {
|
|
||||||
const fuse = new Fuse(input, {
|
|
||||||
includeScore: true,
|
|
||||||
keys: ["languageName"],
|
|
||||||
});
|
|
||||||
|
|
||||||
results = fuse.search(searchQuery).map((res) => res.item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}, [subs, searchQuery, unknownChoice]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpenSubtitlesCaptionView({ id }: { id: string }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useOverlayRouter(id);
|
|
||||||
const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id);
|
|
||||||
const [currentlyDownloading, setCurrentlyDownloading] = useState<
|
|
||||||
string | null
|
|
||||||
>(null);
|
|
||||||
const { selectCaptionById } = useCaptions();
|
|
||||||
const captionList = usePlayerStore((s) => s.captionList);
|
|
||||||
const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList);
|
|
||||||
|
|
||||||
const captions = useMemo(
|
|
||||||
() =>
|
|
||||||
captionList.length !== 0 ? captionList : getHlsCaptionList?.() ?? [],
|
|
||||||
[captionList, getHlsCaptionList],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const subtitleList = useSubtitleList(captions, searchQuery);
|
|
||||||
|
|
||||||
const [downloadReq, startDownload] = useAsyncFn(
|
|
||||||
async (captionId: string) => {
|
|
||||||
setCurrentlyDownloading(captionId);
|
|
||||||
return selectCaptionById(captionId);
|
|
||||||
},
|
|
||||||
[selectCaptionById, setCurrentlyDownloading],
|
|
||||||
);
|
|
||||||
|
|
||||||
const content = subtitleList.map((v) => {
|
|
||||||
return (
|
|
||||||
<CaptionOption
|
|
||||||
// key must use index to prevent url collisions
|
|
||||||
key={v.id}
|
|
||||||
countryCode={v.language}
|
|
||||||
selected={v.id === selectedCaptionId}
|
|
||||||
loading={v.id === currentlyDownloading && downloadReq.loading}
|
|
||||||
error={
|
|
||||||
v.id === currentlyDownloading && downloadReq.error
|
|
||||||
? downloadReq.error.toString()
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onClick={() => startDownload(v.id)}
|
|
||||||
>
|
|
||||||
{v.languageName}
|
|
||||||
</CaptionOption>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<Menu.BackLink onClick={() => router.navigate("/captions")}>
|
|
||||||
{t("player.menus.subtitles.OpenSubtitlesChoice")}
|
|
||||||
</Menu.BackLink>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<Input value={searchQuery} onInput={setSearchQuery} />
|
|
||||||
</div>
|
|
||||||
<Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3">
|
|
||||||
{content}
|
|
||||||
</Menu.ScrollToActiveSection>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default OpenSubtitlesCaptionView;
|
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncFn } from "react-use";
|
||||||
|
|
||||||
|
import { useCaptions } from "@/components/player/hooks/useCaptions";
|
||||||
|
import { Menu } from "@/components/player/internals/ContextMenu";
|
||||||
|
import { Input } from "@/components/player/internals/ContextMenu/Input";
|
||||||
|
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||||
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
|
||||||
|
import { CaptionOption } from "./CaptionsView";
|
||||||
|
import { useSubtitleList } from "./SourceCaptionsView";
|
||||||
|
|
||||||
|
export function OpenSubtitlesCaptionView({
|
||||||
|
id,
|
||||||
|
overlayBackLink,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
overlayBackLink?: true;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useOverlayRouter(id);
|
||||||
|
const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id);
|
||||||
|
const [currentlyDownloading, setCurrentlyDownloading] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const { selectCaptionById } = useCaptions();
|
||||||
|
const captionList = usePlayerStore((s) => s.captionList);
|
||||||
|
const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList);
|
||||||
|
|
||||||
|
const captions = useMemo(
|
||||||
|
() =>
|
||||||
|
captionList.length !== 0 ? captionList : getHlsCaptionList?.() ?? [],
|
||||||
|
[captionList, getHlsCaptionList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const subtitleList = useSubtitleList(
|
||||||
|
captions.filter((x) => x.opensubtitles),
|
||||||
|
searchQuery,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [downloadReq, startDownload] = useAsyncFn(
|
||||||
|
async (captionId: string) => {
|
||||||
|
setCurrentlyDownloading(captionId);
|
||||||
|
return selectCaptionById(captionId);
|
||||||
|
},
|
||||||
|
[selectCaptionById, setCurrentlyDownloading],
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = subtitleList.length
|
||||||
|
? subtitleList.map((v) => {
|
||||||
|
return (
|
||||||
|
<CaptionOption
|
||||||
|
// key must use index to prevent url collisions
|
||||||
|
key={v.id}
|
||||||
|
countryCode={v.language}
|
||||||
|
selected={v.id === selectedCaptionId}
|
||||||
|
loading={v.id === currentlyDownloading && downloadReq.loading}
|
||||||
|
error={
|
||||||
|
v.id === currentlyDownloading && downloadReq.error
|
||||||
|
? downloadReq.error.toString()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => startDownload(v.id)}
|
||||||
|
>
|
||||||
|
{v.languageName}
|
||||||
|
</CaptionOption>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: t("player.menus.subtitles.notFound");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Menu.BackLink
|
||||||
|
onClick={() =>
|
||||||
|
router.navigate(overlayBackLink ? "/captionsOverlay" : "/captions")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("player.menus.subtitles.OpenSubtitlesChoice")}
|
||||||
|
</Menu.BackLink>
|
||||||
|
</div>
|
||||||
|
{captionList.filter((x) => x.opensubtitles).length ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Input value={searchQuery} onInput={setSearchQuery} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3">
|
||||||
|
{!captionList.filter((x) => x.opensubtitles).length ? (
|
||||||
|
<div className="p-4 rounded-xl bg-video-context-light bg-opacity-10 font-medium text-center">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3">
|
||||||
|
{t("player.menus.subtitles.empty")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">{content}</div>
|
||||||
|
)}
|
||||||
|
</Menu.ScrollToActiveSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OpenSubtitlesCaptionView;
|
149
src/components/player/atoms/settings/SourceCaptionsView.tsx
Normal file
149
src/components/player/atoms/settings/SourceCaptionsView.tsx
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncFn } from "react-use";
|
||||||
|
|
||||||
|
import { useCaptions } from "@/components/player/hooks/useCaptions";
|
||||||
|
import { Menu } from "@/components/player/internals/ContextMenu";
|
||||||
|
import { Input } from "@/components/player/internals/ContextMenu/Input";
|
||||||
|
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||||
|
import { CaptionListItem } from "@/stores/player/slices/source";
|
||||||
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
import {
|
||||||
|
getPrettyLanguageNameFromLocale,
|
||||||
|
sortLangCodes,
|
||||||
|
} from "@/utils/language";
|
||||||
|
|
||||||
|
import { CaptionOption } from "./CaptionsView";
|
||||||
|
|
||||||
|
export function useSubtitleList(subs: CaptionListItem[], searchQuery: string) {
|
||||||
|
const { t: translate } = useTranslation();
|
||||||
|
const unknownChoice = translate("player.menus.subtitles.unknownLanguage");
|
||||||
|
return useMemo(() => {
|
||||||
|
const input = subs.map((t) => ({
|
||||||
|
...t,
|
||||||
|
languageName:
|
||||||
|
getPrettyLanguageNameFromLocale(t.language) ?? unknownChoice,
|
||||||
|
}));
|
||||||
|
const sorted = sortLangCodes(input.map((t) => t.language));
|
||||||
|
let results = input.sort((a, b) => {
|
||||||
|
return sorted.indexOf(a.language) - sorted.indexOf(b.language);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (searchQuery.trim().length > 0) {
|
||||||
|
const fuse = new Fuse(input, {
|
||||||
|
includeScore: true,
|
||||||
|
keys: ["languageName"],
|
||||||
|
});
|
||||||
|
|
||||||
|
results = fuse.search(searchQuery).map((res) => res.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}, [subs, searchQuery, unknownChoice]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceCaptionsView({
|
||||||
|
id,
|
||||||
|
overlayBackLink,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
overlayBackLink?: true;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useOverlayRouter(id);
|
||||||
|
const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id);
|
||||||
|
const [currentlyDownloading, setCurrentlyDownloading] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const { selectCaptionById } = useCaptions();
|
||||||
|
const captionList = usePlayerStore((s) => s.captionList);
|
||||||
|
const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList);
|
||||||
|
|
||||||
|
const captions = useMemo(
|
||||||
|
() =>
|
||||||
|
captionList.length !== 0 ? captionList : getHlsCaptionList?.() ?? [],
|
||||||
|
[captionList, getHlsCaptionList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const subtitleList = useSubtitleList(
|
||||||
|
captions.filter((x) => !x.opensubtitles),
|
||||||
|
searchQuery,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [downloadReq, startDownload] = useAsyncFn(
|
||||||
|
async (captionId: string) => {
|
||||||
|
setCurrentlyDownloading(captionId);
|
||||||
|
return selectCaptionById(captionId);
|
||||||
|
},
|
||||||
|
[selectCaptionById, setCurrentlyDownloading],
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = subtitleList.length
|
||||||
|
? subtitleList.map((v) => {
|
||||||
|
return (
|
||||||
|
<CaptionOption
|
||||||
|
// key must use index to prevent url collisions
|
||||||
|
key={v.id}
|
||||||
|
countryCode={v.language}
|
||||||
|
selected={v.id === selectedCaptionId}
|
||||||
|
loading={v.id === currentlyDownloading && downloadReq.loading}
|
||||||
|
error={
|
||||||
|
v.id === currentlyDownloading && downloadReq.error
|
||||||
|
? downloadReq.error.toString()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => startDownload(v.id)}
|
||||||
|
>
|
||||||
|
{v.languageName}
|
||||||
|
</CaptionOption>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: t("player.menus.subtitles.notFound");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Menu.BackLink
|
||||||
|
onClick={() =>
|
||||||
|
router.navigate(overlayBackLink ? "/captionsOverlay" : "/captions")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("player.menus.subtitles.SourceChoice")}
|
||||||
|
</Menu.BackLink>
|
||||||
|
</div>
|
||||||
|
{captionList.filter((x) => !x.opensubtitles).length ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Input value={searchQuery} onInput={setSearchQuery} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3">
|
||||||
|
{!captionList.filter((x) => !x.opensubtitles).length ? (
|
||||||
|
<div className="p-4 rounded-xl bg-video-context-light bg-opacity-10 font-medium text-center">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3">
|
||||||
|
{t("player.menus.subtitles.empty")}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
router.navigate(
|
||||||
|
overlayBackLink
|
||||||
|
? "/captions/opensubtitlesOverlay"
|
||||||
|
: "/captions/opensubtitles",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="p-1 w-3/4 rounded tabbable duration-200 bg-opacity-10 bg-video-context-light hover:bg-opacity-20"
|
||||||
|
>
|
||||||
|
{t("player.menus.subtitles.scrapeButton")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">{content}</div>
|
||||||
|
)}
|
||||||
|
</Menu.ScrollToActiveSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SourceCaptionsView;
|
|
@ -123,24 +123,9 @@ export function SelectableLink(props: {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
error?: ReactNode;
|
error?: ReactNode;
|
||||||
chevron?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
let rightContent;
|
let rightContent;
|
||||||
if (props.selected) {
|
if (props.selected) {
|
||||||
if (props.chevron) {
|
|
||||||
rightContent = (
|
|
||||||
<span className="flex items-center">
|
|
||||||
<Icon
|
|
||||||
icon={Icons.CIRCLE_CHECK}
|
|
||||||
className="text-xl text-video-context-type-accent"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
className="text-white text-xl ml-1 -mr-1.5"
|
|
||||||
icon={Icons.CHEVRON_RIGHT}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
rightContent = (
|
rightContent = (
|
||||||
<Icon
|
<Icon
|
||||||
icon={Icons.CIRCLE_CHECK}
|
icon={Icons.CIRCLE_CHECK}
|
||||||
|
@ -148,11 +133,6 @@ export function SelectableLink(props: {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (props.chevron) {
|
|
||||||
rightContent = (
|
|
||||||
<Icon className="text-xl ml-1 -mr-1.5" icon={Icons.CHEVRON_RIGHT} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (props.error)
|
if (props.error)
|
||||||
rightContent = (
|
rightContent = (
|
||||||
<span className="flex items-center text-video-context-error">
|
<span className="flex items-center text-video-context-error">
|
||||||
|
|
|
@ -111,7 +111,10 @@ export function PlayerPart(props: PlayerPartProps) {
|
||||||
) : null}
|
) : null}
|
||||||
{status === playerStatus.PLAYBACK_ERROR ||
|
{status === playerStatus.PLAYBACK_ERROR ||
|
||||||
status === playerStatus.PLAYING ? (
|
status === playerStatus.PLAYING ? (
|
||||||
|
<>
|
||||||
|
<Player.Captions />
|
||||||
<Player.Settings />
|
<Player.Settings />
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<Player.Fullscreen />
|
<Player.Fullscreen />
|
||||||
</div>
|
</div>
|
||||||
|
@ -121,7 +124,12 @@ export function PlayerPart(props: PlayerPartProps) {
|
||||||
<div className="flex justify-center space-x-3">
|
<div className="flex justify-center space-x-3">
|
||||||
{status === playerStatus.PLAYING ? <Player.Pip /> : null}
|
{status === playerStatus.PLAYING ? <Player.Pip /> : null}
|
||||||
<Player.Episodes />
|
<Player.Episodes />
|
||||||
{status === playerStatus.PLAYING ? <Player.Settings /> : null}
|
{status === playerStatus.PLAYING ? (
|
||||||
|
<>
|
||||||
|
<Player.Captions />
|
||||||
|
<Player.Settings />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Player.Fullscreen />
|
<Player.Fullscreen />
|
||||||
|
|
Loading…
Reference in a new issue