mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-21 06:52:18 +00:00
unify source and external subtitle views
This commit is contained in:
parent
f0736c60f1
commit
f03fbdfc6c
6 changed files with 227 additions and 366 deletions
|
|
@ -691,10 +691,12 @@
|
||||||
"subtitles": {
|
"subtitles": {
|
||||||
"customChoice": "Drop or upload file",
|
"customChoice": "Drop or upload file",
|
||||||
"customizeLabel": "Customize",
|
"customizeLabel": "Customize",
|
||||||
|
"previewLabel": "Subtitle preview:",
|
||||||
"offChoice": "Off",
|
"offChoice": "Off",
|
||||||
"onChoice": "On",
|
"onChoice": "On",
|
||||||
"SourceChoice": "Source Subtitles",
|
"SourceChoice": "Source Subtitles",
|
||||||
"OpenSubtitlesChoice": "External Subtitles",
|
"OpenSubtitlesChoice": "External Subtitles",
|
||||||
|
"loadingExternal": "Loading external subtitles...",
|
||||||
"settings": {
|
"settings": {
|
||||||
"backlink": "Custom subtitles",
|
"backlink": "Custom subtitles",
|
||||||
"delay": "Subtitle delay",
|
"delay": "Subtitle delay",
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,9 @@ 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/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";
|
|
||||||
import { WatchPartyView } from "./settings/WatchPartyView";
|
import { WatchPartyView } from "./settings/WatchPartyView";
|
||||||
|
|
||||||
function SettingsOverlay({ id }: { id: string }) {
|
function SettingsOverlay({ id }: { id: string }) {
|
||||||
|
|
@ -55,7 +53,7 @@ function SettingsOverlay({ id }: { id: string }) {
|
||||||
<AudioView id={id} />
|
<AudioView id={id} />
|
||||||
</Menu.Card>
|
</Menu.Card>
|
||||||
</OverlayPage>
|
</OverlayPage>
|
||||||
<OverlayPage id={id} path="/captions" width={343} height={320}>
|
<OverlayPage id={id} path="/captions" width={343} height={452}>
|
||||||
<Menu.CardWithScrollable>
|
<Menu.CardWithScrollable>
|
||||||
<CaptionsView id={id} backLink />
|
<CaptionsView id={id} backLink />
|
||||||
</Menu.CardWithScrollable>
|
</Menu.CardWithScrollable>
|
||||||
|
|
@ -66,43 +64,6 @@ function SettingsOverlay({ id }: { id: string }) {
|
||||||
<CaptionsView id={id} />
|
<CaptionsView id={id} />
|
||||||
</Menu.CardWithScrollable>
|
</Menu.CardWithScrollable>
|
||||||
</OverlayPage>
|
</OverlayPage>
|
||||||
<OverlayPage
|
|
||||||
id={id}
|
|
||||||
path="/captions/opensubtitles"
|
|
||||||
width={343}
|
|
||||||
height={452}
|
|
||||||
>
|
|
||||||
<Menu.Card>
|
|
||||||
<OpenSubtitlesCaptionView id={id} />
|
|
||||||
</Menu.Card>
|
|
||||||
</OverlayPage>
|
|
||||||
{/* This is used by the captions shortcut in bottomControls of player */}
|
|
||||||
<OverlayPage
|
|
||||||
id={id}
|
|
||||||
path="/captions/opensubtitlesOverlay"
|
|
||||||
width={343}
|
|
||||||
height={452}
|
|
||||||
>
|
|
||||||
<Menu.Card>
|
|
||||||
<OpenSubtitlesCaptionView id={id} overlayBackLink />
|
|
||||||
</Menu.Card>
|
|
||||||
</OverlayPage>
|
|
||||||
<OverlayPage id={id} path="/captions/source" width={343} height={452}>
|
|
||||||
<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={452}
|
|
||||||
>
|
|
||||||
<Menu.Card>
|
|
||||||
<SourceCaptionsView id={id} overlayBackLink />
|
|
||||||
</Menu.Card>
|
|
||||||
</OverlayPage>
|
|
||||||
<OverlayPage id={id} path="/captions/settings" width={343} height={452}>
|
<OverlayPage id={id} path="/captions/settings" width={343} height={452}>
|
||||||
<Menu.Card>
|
<Menu.Card>
|
||||||
<CaptionSettingsView id={id} />
|
<CaptionSettingsView id={id} />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
import { type DragEvent, useEffect, useMemo, useRef, useState } from "react";
|
import { type DragEvent, useEffect, 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";
|
||||||
|
|
@ -9,12 +11,21 @@ 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 { fixUTF8Encoding } from "@/components/player/utils/captions";
|
import {
|
||||||
|
captionIsVisible,
|
||||||
|
fixUTF8Encoding,
|
||||||
|
parseSubtitles,
|
||||||
|
} from "@/components/player/utils/captions";
|
||||||
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 { getPrettyLanguageNameFromLocale } from "@/utils/language";
|
import {
|
||||||
|
getPrettyLanguageNameFromLocale,
|
||||||
|
sortLangCodes,
|
||||||
|
} from "@/utils/language";
|
||||||
|
|
||||||
export function CaptionOption(props: {
|
export function CaptionOption(props: {
|
||||||
countryCode?: string;
|
countryCode?: string;
|
||||||
|
|
@ -141,6 +152,35 @@ export function CaptionOption(props: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hook to filter and sort subtitle list with search
|
||||||
|
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,
|
||||||
|
threshold: 0.3, // Lower threshold = stricter matching (0 = exact, 1 = match anything)
|
||||||
|
keys: ["languageName"],
|
||||||
|
});
|
||||||
|
|
||||||
|
results = fuse.search(searchQuery).map((res) => res.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}, [subs, searchQuery, unknownChoice]);
|
||||||
|
}
|
||||||
|
|
||||||
export 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);
|
||||||
|
|
@ -198,11 +238,61 @@ export function CaptionsView({
|
||||||
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 { disable, toggleLastUsed } = useCaptions();
|
const { disable, selectCaptionById } = useCaptions();
|
||||||
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(
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
(s) => s.caption.selected?.language,
|
const [currentlyDownloading, setCurrentlyDownloading] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const videoTime = usePlayerStore((s) => s.progress.time);
|
||||||
|
const srtData = usePlayerStore((s) => s.caption.selected?.srtData);
|
||||||
|
const language = usePlayerStore((s) => s.caption.selected?.language);
|
||||||
|
const captionList = usePlayerStore((s) => s.captionList);
|
||||||
|
const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList);
|
||||||
|
const isLoadingExternalSubtitles = usePlayerStore(
|
||||||
|
(s) => s.isLoadingExternalSubtitles,
|
||||||
|
);
|
||||||
|
const delay = useSubtitleStore((s) => s.delay);
|
||||||
|
|
||||||
|
// Get combined caption list
|
||||||
|
const captions = useMemo(
|
||||||
|
() =>
|
||||||
|
captionList.length !== 0 ? captionList : (getHlsCaptionList?.() ?? []),
|
||||||
|
[captionList, getHlsCaptionList],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Split captions into source and external (opensubtitles)
|
||||||
|
const sourceCaptions = useMemo(
|
||||||
|
() => captions.filter((x) => !x.opensubtitles),
|
||||||
|
[captions],
|
||||||
|
);
|
||||||
|
const externalCaptions = useMemo(
|
||||||
|
() => captions.filter((x) => x.opensubtitles),
|
||||||
|
[captions],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter lists based on search query
|
||||||
|
const sourceList = useSubtitleList(sourceCaptions, searchQuery);
|
||||||
|
const externalList = useSubtitleList(externalCaptions, searchQuery);
|
||||||
|
|
||||||
|
// Get current subtitle text preview
|
||||||
|
const currentSubtitleText = useMemo(() => {
|
||||||
|
if (!srtData || !selectedCaptionId) return null;
|
||||||
|
const parsedCaptions = parseSubtitles(srtData, language);
|
||||||
|
const visibleCaption = parsedCaptions.find(({ start, end }) =>
|
||||||
|
captionIsVisible(start, end, delay, videoTime),
|
||||||
|
);
|
||||||
|
return visibleCaption?.content;
|
||||||
|
}, [srtData, language, delay, videoTime, selectedCaptionId]);
|
||||||
|
|
||||||
|
// Download handler
|
||||||
|
const [downloadReq, startDownload] = useAsyncFn(
|
||||||
|
async (captionId: string) => {
|
||||||
|
setCurrentlyDownloading(captionId);
|
||||||
|
return selectCaptionById(captionId);
|
||||||
|
},
|
||||||
|
[selectCaptionById, setCurrentlyDownloading],
|
||||||
);
|
);
|
||||||
|
|
||||||
function onDrop(event: DragEvent<HTMLDivElement>) {
|
function onDrop(event: DragEvent<HTMLDivElement>) {
|
||||||
|
|
@ -219,7 +309,6 @@ export function CaptionsView({
|
||||||
reader.addEventListener("load", (e) => {
|
reader.addEventListener("load", (e) => {
|
||||||
if (!e.target || typeof e.target.result !== "string") return;
|
if (!e.target || typeof e.target.result !== "string") return;
|
||||||
|
|
||||||
// Ensure the data is in UTF-8 and fix any encoding issues
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const decoder = new TextDecoder("utf-8");
|
const decoder = new TextDecoder("utf-8");
|
||||||
const utf8Bytes = encoder.encode(e.target.result);
|
const utf8Bytes = encoder.encode(e.target.result);
|
||||||
|
|
@ -238,10 +327,31 @@ export function CaptionsView({
|
||||||
reader.readAsText(firstFile, "utf-8");
|
reader.readAsText(firstFile, "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedLanguagePretty = selectedCaptionLanguage
|
// Render subtitle option
|
||||||
? (getPrettyLanguageNameFromLocale(selectedCaptionLanguage) ??
|
const renderSubtitleOption = (
|
||||||
t("player.menus.subtitles.unknownLanguage"))
|
v: CaptionListItem & { languageName: string },
|
||||||
: undefined;
|
) => (
|
||||||
|
<CaptionOption
|
||||||
|
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)}
|
||||||
|
flag
|
||||||
|
subtitleUrl={v.url}
|
||||||
|
subtitleType={v.type}
|
||||||
|
subtitleSource={v.source}
|
||||||
|
subtitleEncoding={v.encoding}
|
||||||
|
isHearingImpaired={v.isHearingImpaired}
|
||||||
|
>
|
||||||
|
{v.languageName}
|
||||||
|
</CaptionOption>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -298,50 +408,108 @@ export function CaptionsView({
|
||||||
}}
|
}}
|
||||||
onDrop={(event) => onDrop(event)}
|
onDrop={(event) => onDrop(event)}
|
||||||
>
|
>
|
||||||
|
{/* Current subtitle preview */}
|
||||||
|
{selectedCaptionId && (
|
||||||
|
<div className="mt-3 p-2 rounded-xl bg-video-context-light bg-opacity-10 text-center sm:hidden">
|
||||||
|
<div className="text-sm text-video-context-type-secondary mb-1">
|
||||||
|
{t("player.menus.subtitles.previewLabel")}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-base font-medium min-h-[3rem] flex items-center justify-center"
|
||||||
|
style={{ minHeight: "3rem" }}
|
||||||
|
>
|
||||||
|
{currentSubtitleText ? (
|
||||||
|
<div
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: currentSubtitleText.replaceAll(/\r?\n/g, "<br />"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-video-context-type-secondary italic">
|
||||||
|
...{" "}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<Input value={searchQuery} onInput={setSearchQuery} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3">
|
<Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3">
|
||||||
|
{/* Off button */}
|
||||||
<CaptionOption
|
<CaptionOption
|
||||||
onClick={() => disable()}
|
onClick={() => disable()}
|
||||||
selected={!selectedCaptionId}
|
selected={!selectedCaptionId}
|
||||||
>
|
>
|
||||||
{t("player.menus.subtitles.offChoice")}
|
{t("player.menus.subtitles.offChoice")}
|
||||||
</CaptionOption>
|
</CaptionOption>
|
||||||
<CaptionOption
|
|
||||||
onClick={() => toggleLastUsed().catch(() => {})}
|
{/* Custom upload option */}
|
||||||
selected={!!selectedCaptionId}
|
|
||||||
>
|
|
||||||
{t("player.menus.subtitles.onChoice")}
|
|
||||||
</CaptionOption>
|
|
||||||
<CustomCaptionOption />
|
<CustomCaptionOption />
|
||||||
<Menu.ChevronLink
|
|
||||||
onClick={() =>
|
{/* No subtitles available message */}
|
||||||
router.navigate(
|
{!isLoadingExternalSubtitles &&
|
||||||
backLink ? "/captions/source" : "/captions/sourceOverlay",
|
sourceCaptions.length === 0 &&
|
||||||
)
|
externalCaptions.length === 0 && (
|
||||||
}
|
<div className="p-4 mt-6 rounded-xl bg-video-context-light bg-opacity-10 text-center">
|
||||||
rightText={
|
<div className="text-video-context-type-secondary">
|
||||||
useSubtitleStore((s) => s.isOpenSubtitles)
|
{t("player.menus.subtitles.empty")}
|
||||||
? ""
|
</div>
|
||||||
: selectedLanguagePretty
|
</div>
|
||||||
}
|
)}
|
||||||
>
|
|
||||||
{t("player.menus.subtitles.SourceChoice")}
|
{/* Loading external subtitles */}
|
||||||
</Menu.ChevronLink>
|
{isLoadingExternalSubtitles && externalCaptions.length === 0 && (
|
||||||
<Menu.ChevronLink
|
<div className="p-4 mt-6 rounded-xl bg-video-context-light bg-opacity-10 text-center">
|
||||||
onClick={() =>
|
<div className="text-video-context-type-secondary">
|
||||||
router.navigate(
|
{t("player.menus.subtitles.loadingExternal")}
|
||||||
backLink
|
</div>
|
||||||
? "/captions/opensubtitles"
|
</div>
|
||||||
: "/captions/opensubtitlesOverlay",
|
)}
|
||||||
)
|
|
||||||
}
|
{/* Source Subtitles Section */}
|
||||||
rightText={
|
{sourceCaptions.length > 0 && (
|
||||||
useSubtitleStore((s) => s.isOpenSubtitles)
|
<>
|
||||||
? selectedLanguagePretty
|
<div className="text-sm font-semibold text-video-context-type-secondary pt-2 mb-2">
|
||||||
: ""
|
{t("player.menus.subtitles.SourceChoice")}
|
||||||
}
|
</div>
|
||||||
>
|
{sourceList.length > 0 ? (
|
||||||
{t("player.menus.subtitles.OpenSubtitlesChoice")}
|
sourceList.map(renderSubtitleOption)
|
||||||
</Menu.ChevronLink>
|
) : (
|
||||||
|
<div className="text-center text-video-context-type-secondary py-2">
|
||||||
|
{t("player.menus.subtitles.notFound")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* External Subtitles Section */}
|
||||||
|
{externalCaptions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-semibold text-video-context-type-secondary pt-2 mb-2">
|
||||||
|
{t("player.menus.subtitles.OpenSubtitlesChoice")}
|
||||||
|
</div>
|
||||||
|
{externalList.length > 0 ? (
|
||||||
|
externalList.map(renderSubtitleOption)
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-video-context-type-secondary py-2">
|
||||||
|
{t("player.menus.subtitles.notFound")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading indicator for external subtitles while source exists */}
|
||||||
|
{isLoadingExternalSubtitles && sourceCaptions.length > 0 && (
|
||||||
|
<div className="text-center text-video-context-type-secondary py-4 mt-2">
|
||||||
|
{t("player.menus.subtitles.loadingExternal") ||
|
||||||
|
"Loading external subtitles..."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Menu.ScrollToActiveSection>
|
</Menu.ScrollToActiveSection>
|
||||||
</FileDropHandler>
|
</FileDropHandler>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
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 addExternalSubtitles = usePlayerStore((s) => s.addExternalSubtitles);
|
|
||||||
|
|
||||||
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 [refreshReq, startRefresh] = useAsyncFn(async () => {
|
|
||||||
return addExternalSubtitles();
|
|
||||||
}, [addExternalSubtitles]);
|
|
||||||
|
|
||||||
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)}
|
|
||||||
flag
|
|
||||||
subtitleUrl={v.url}
|
|
||||||
subtitleType={v.type}
|
|
||||||
// subtitle details from wyzie
|
|
||||||
subtitleSource={v.source}
|
|
||||||
subtitleEncoding={v.encoding}
|
|
||||||
isHearingImpaired={v.isHearingImpaired}
|
|
||||||
>
|
|
||||||
{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")}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => startRefresh()}
|
|
||||||
disabled={refreshReq.loading}
|
|
||||||
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 OpenSubtitlesCaptionView;
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
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)}
|
|
||||||
flag
|
|
||||||
subtitleUrl={v.url}
|
|
||||||
subtitleType={v.type}
|
|
||||||
// subtitle details from wyzie
|
|
||||||
subtitleSource={v.source}
|
|
||||||
subtitleEncoding={v.encoding}
|
|
||||||
isHearingImpaired={v.isHearingImpaired}
|
|
||||||
>
|
|
||||||
{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;
|
|
||||||
|
|
@ -83,6 +83,7 @@ export interface SourceSlice {
|
||||||
currentQuality: SourceQuality | null;
|
currentQuality: SourceQuality | null;
|
||||||
currentAudioTrack: AudioTrack | null;
|
currentAudioTrack: AudioTrack | null;
|
||||||
captionList: CaptionListItem[];
|
captionList: CaptionListItem[];
|
||||||
|
isLoadingExternalSubtitles: boolean;
|
||||||
caption: {
|
caption: {
|
||||||
selected: Caption | null;
|
selected: Caption | null;
|
||||||
asTrack: boolean;
|
asTrack: boolean;
|
||||||
|
|
@ -135,6 +136,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
||||||
qualities: [],
|
qualities: [],
|
||||||
audioTracks: [],
|
audioTracks: [],
|
||||||
captionList: [],
|
captionList: [],
|
||||||
|
isLoadingExternalSubtitles: false,
|
||||||
currentQuality: null,
|
currentQuality: null,
|
||||||
currentAudioTrack: null,
|
currentAudioTrack: null,
|
||||||
status: playerStatus.IDLE,
|
status: playerStatus.IDLE,
|
||||||
|
|
@ -258,6 +260,10 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
||||||
const store = get();
|
const store = get();
|
||||||
if (!store.meta) return;
|
if (!store.meta) return;
|
||||||
|
|
||||||
|
set((s) => {
|
||||||
|
s.isLoadingExternalSubtitles = true;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { scrapeExternalSubtitles } = await import(
|
const { scrapeExternalSubtitles } = await import(
|
||||||
"@/utils/externalSubtitles"
|
"@/utils/externalSubtitles"
|
||||||
|
|
@ -277,6 +283,10 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to scrape external subtitles:", error);
|
console.error("Failed to scrape external subtitles:", error);
|
||||||
|
} finally {
|
||||||
|
set((s) => {
|
||||||
|
s.isLoadingExternalSubtitles = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue