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:
Cooper 2024-07-11 13:37:59 -04:00 committed by GitHub
commit 1e79f649f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 435 additions and 295 deletions

View File

@ -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": {

View 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>
);
}

View File

@ -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} />

View File

@ -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";

View File

@ -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">

View File

@ -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>
</> </>

View File

@ -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;

View File

@ -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;

View 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;

View File

@ -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">

View File

@ -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 />