From f03fbdfc6cb9119b491055e82f2126f0ee5436db Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 12 Oct 2025 12:19:01 -0600 Subject: [PATCH] unify source and external subtitle views --- src/assets/locales/en.json | 2 + src/components/player/atoms/Settings.tsx | 41 +-- .../player/atoms/settings/CaptionsView.tsx | 260 ++++++++++++++---- .../settings/OpensubtitlesCaptionsView.tsx | 124 --------- .../atoms/settings/SourceCaptionsView.tsx | 156 ----------- src/stores/player/slices/source.ts | 10 + 6 files changed, 227 insertions(+), 366 deletions(-) delete mode 100644 src/components/player/atoms/settings/OpensubtitlesCaptionsView.tsx delete mode 100644 src/components/player/atoms/settings/SourceCaptionsView.tsx diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 76f4a155..255b9408 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -691,10 +691,12 @@ "subtitles": { "customChoice": "Drop or upload file", "customizeLabel": "Customize", + "previewLabel": "Subtitle preview:", "offChoice": "Off", "onChoice": "On", "SourceChoice": "Source Subtitles", "OpenSubtitlesChoice": "External Subtitles", + "loadingExternal": "Loading external subtitles...", "settings": { "backlink": "Custom subtitles", "delay": "Subtitle delay", diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index 93cbccb9..b8aad79e 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -18,11 +18,9 @@ import { AudioView } from "./settings/AudioView"; import { CaptionSettingsView } from "./settings/CaptionSettingsView"; import { CaptionsView } from "./settings/CaptionsView"; import { DownloadRoutes } from "./settings/Downloads"; -import { OpenSubtitlesCaptionView } from "./settings/OpensubtitlesCaptionsView"; import { PlaybackSettingsView } from "./settings/PlaybackSettingsView"; import { QualityView } from "./settings/QualityView"; import { SettingsMenu } from "./settings/SettingsMenu"; -import SourceCaptionsView from "./settings/SourceCaptionsView"; import { WatchPartyView } from "./settings/WatchPartyView"; function SettingsOverlay({ id }: { id: string }) { @@ -55,7 +53,7 @@ function SettingsOverlay({ id }: { id: string }) { - + @@ -66,43 +64,6 @@ function SettingsOverlay({ id }: { id: string }) { - - - - - - {/* This is used by the captions shortcut in bottomControls of player */} - - - - - - - - - - - {/* This is used by the captions shortcut in bottomControls of player */} - - - - - diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index 1babea73..af8e11ea 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -1,6 +1,8 @@ import classNames from "classnames"; +import Fuse from "fuse.js"; import { type DragEvent, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useAsyncFn } from "react-use"; import { convert } from "subsrt-ts"; import { subtitleTypeList } from "@/backend/helpers/subs"; @@ -9,12 +11,21 @@ import { FlagIcon } from "@/components/FlagIcon"; import { Icon, Icons } from "@/components/Icon"; 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 { fixUTF8Encoding } from "@/components/player/utils/captions"; +import { + captionIsVisible, + fixUTF8Encoding, + parseSubtitles, +} from "@/components/player/utils/captions"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; +import { CaptionListItem } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; import { useSubtitleStore } from "@/stores/subtitles"; -import { getPrettyLanguageNameFromLocale } from "@/utils/language"; +import { + getPrettyLanguageNameFromLocale, + sortLangCodes, +} from "@/utils/language"; export function CaptionOption(props: { 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() { const { t } = useTranslation(); const lang = usePlayerStore((s) => s.caption.selected?.language); @@ -198,11 +238,61 @@ export function CaptionsView({ const { t } = useTranslation(); const router = useOverlayRouter(id); const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id); - const { disable, toggleLastUsed } = useCaptions(); + const { disable, selectCaptionById } = useCaptions(); const [dragging, setDragging] = useState(false); const setCaption = usePlayerStore((s) => s.setCaption); - const selectedCaptionLanguage = usePlayerStore( - (s) => s.caption.selected?.language, + const [searchQuery, setSearchQuery] = useState(""); + 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) { @@ -219,7 +309,6 @@ export function CaptionsView({ reader.addEventListener("load", (e) => { 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 decoder = new TextDecoder("utf-8"); const utf8Bytes = encoder.encode(e.target.result); @@ -238,10 +327,31 @@ export function CaptionsView({ reader.readAsText(firstFile, "utf-8"); } - const selectedLanguagePretty = selectedCaptionLanguage - ? (getPrettyLanguageNameFromLocale(selectedCaptionLanguage) ?? - t("player.menus.subtitles.unknownLanguage")) - : undefined; + // Render subtitle option + const renderSubtitleOption = ( + v: CaptionListItem & { languageName: string }, + ) => ( + startDownload(v.id)} + flag + subtitleUrl={v.url} + subtitleType={v.type} + subtitleSource={v.source} + subtitleEncoding={v.encoding} + isHearingImpaired={v.isHearingImpaired} + > + {v.languageName} + + ); return ( <> @@ -298,50 +408,108 @@ export function CaptionsView({ }} onDrop={(event) => onDrop(event)} > + {/* Current subtitle preview */} + {selectedCaptionId && ( +
+
+ {t("player.menus.subtitles.previewLabel")} +
+
+ {currentSubtitleText ? ( +
"), + }} + /> + ) : ( + + ...{" "} + + )} +
+
+ )} + + {/* Search input */} +
+ +
+ + {/* Off button */} disable()} selected={!selectedCaptionId} > {t("player.menus.subtitles.offChoice")} - toggleLastUsed().catch(() => {})} - selected={!!selectedCaptionId} - > - {t("player.menus.subtitles.onChoice")} - + + {/* Custom upload option */} - - router.navigate( - backLink ? "/captions/source" : "/captions/sourceOverlay", - ) - } - rightText={ - useSubtitleStore((s) => s.isOpenSubtitles) - ? "" - : selectedLanguagePretty - } - > - {t("player.menus.subtitles.SourceChoice")} - - - router.navigate( - backLink - ? "/captions/opensubtitles" - : "/captions/opensubtitlesOverlay", - ) - } - rightText={ - useSubtitleStore((s) => s.isOpenSubtitles) - ? selectedLanguagePretty - : "" - } - > - {t("player.menus.subtitles.OpenSubtitlesChoice")} - + + {/* No subtitles available message */} + {!isLoadingExternalSubtitles && + sourceCaptions.length === 0 && + externalCaptions.length === 0 && ( +
+
+ {t("player.menus.subtitles.empty")} +
+
+ )} + + {/* Loading external subtitles */} + {isLoadingExternalSubtitles && externalCaptions.length === 0 && ( +
+
+ {t("player.menus.subtitles.loadingExternal")} +
+
+ )} + + {/* Source Subtitles Section */} + {sourceCaptions.length > 0 && ( + <> +
+ {t("player.menus.subtitles.SourceChoice")} +
+ {sourceList.length > 0 ? ( + sourceList.map(renderSubtitleOption) + ) : ( +
+ {t("player.menus.subtitles.notFound")} +
+ )} + + )} + + {/* External Subtitles Section */} + {externalCaptions.length > 0 && ( + <> +
+ {t("player.menus.subtitles.OpenSubtitlesChoice")} +
+ {externalList.length > 0 ? ( + externalList.map(renderSubtitleOption) + ) : ( +
+ {t("player.menus.subtitles.notFound")} +
+ )} + + )} + + {/* Loading indicator for external subtitles while source exists */} + {isLoadingExternalSubtitles && sourceCaptions.length > 0 && ( +
+ {t("player.menus.subtitles.loadingExternal") || + "Loading external subtitles..."} +
+ )}
diff --git a/src/components/player/atoms/settings/OpensubtitlesCaptionsView.tsx b/src/components/player/atoms/settings/OpensubtitlesCaptionsView.tsx deleted file mode 100644 index 15150654..00000000 --- a/src/components/player/atoms/settings/OpensubtitlesCaptionsView.tsx +++ /dev/null @@ -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 ( - 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} - - ); - }) - : t("player.menus.subtitles.notFound"); - - return ( - <> -
- - router.navigate(overlayBackLink ? "/captionsOverlay" : "/captions") - } - > - {t("player.menus.subtitles.OpenSubtitlesChoice")} - -
- {captionList.filter((x) => x.opensubtitles).length ? ( -
- -
- ) : null} - - {!captionList.filter((x) => x.opensubtitles).length ? ( -
-
- {t("player.menus.subtitles.empty")} - -
-
- ) : ( -
{content}
- )} -
- - ); -} - -export default OpenSubtitlesCaptionView; diff --git a/src/components/player/atoms/settings/SourceCaptionsView.tsx b/src/components/player/atoms/settings/SourceCaptionsView.tsx deleted file mode 100644 index 9671ad5c..00000000 --- a/src/components/player/atoms/settings/SourceCaptionsView.tsx +++ /dev/null @@ -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 ( - 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} - - ); - }) - : t("player.menus.subtitles.notFound"); - - return ( - <> -
- - router.navigate(overlayBackLink ? "/captionsOverlay" : "/captions") - } - > - {t("player.menus.subtitles.SourceChoice")} - -
- {captionList.filter((x) => !x.opensubtitles).length ? ( -
- -
- ) : null} - - {!captionList.filter((x) => !x.opensubtitles).length ? ( -
-
- {t("player.menus.subtitles.empty")} - -
-
- ) : ( -
{content}
- )} -
- - ); -} - -export default SourceCaptionsView; diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 627f16e5..f4496af9 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -83,6 +83,7 @@ export interface SourceSlice { currentQuality: SourceQuality | null; currentAudioTrack: AudioTrack | null; captionList: CaptionListItem[]; + isLoadingExternalSubtitles: boolean; caption: { selected: Caption | null; asTrack: boolean; @@ -135,6 +136,7 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ qualities: [], audioTracks: [], captionList: [], + isLoadingExternalSubtitles: false, currentQuality: null, currentAudioTrack: null, status: playerStatus.IDLE, @@ -258,6 +260,10 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ const store = get(); if (!store.meta) return; + set((s) => { + s.isLoadingExternalSubtitles = true; + }); + try { const { scrapeExternalSubtitles } = await import( "@/utils/externalSubtitles" @@ -277,6 +283,10 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ } } catch (error) { console.error("Failed to scrape external subtitles:", error); + } finally { + set((s) => { + s.isLoadingExternalSubtitles = false; + }); } }, });