unify source and external subtitle views

This commit is contained in:
Pas 2025-10-12 12:19:01 -06:00
parent f0736c60f1
commit f03fbdfc6c
6 changed files with 227 additions and 366 deletions

View file

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

View file

@ -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 }) {
<AudioView id={id} />
</Menu.Card>
</OverlayPage>
<OverlayPage id={id} path="/captions" width={343} height={320}>
<OverlayPage id={id} path="/captions" width={343} height={452}>
<Menu.CardWithScrollable>
<CaptionsView id={id} backLink />
</Menu.CardWithScrollable>
@ -66,43 +64,6 @@ function SettingsOverlay({ id }: { id: string }) {
<CaptionsView id={id} />
</Menu.CardWithScrollable>
</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}>
<Menu.Card>
<CaptionSettingsView id={id} />

View file

@ -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<HTMLDivElement>) {
@ -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 },
) => (
<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 (
<>
@ -298,50 +408,108 @@ export function CaptionsView({
}}
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">
{/* Off button */}
<CaptionOption
onClick={() => disable()}
selected={!selectedCaptionId}
>
{t("player.menus.subtitles.offChoice")}
</CaptionOption>
<CaptionOption
onClick={() => toggleLastUsed().catch(() => {})}
selected={!!selectedCaptionId}
>
{t("player.menus.subtitles.onChoice")}
</CaptionOption>
{/* Custom upload option */}
<CustomCaptionOption />
<Menu.ChevronLink
onClick={() =>
router.navigate(
backLink ? "/captions/source" : "/captions/sourceOverlay",
)
}
rightText={
useSubtitleStore((s) => s.isOpenSubtitles)
? ""
: selectedLanguagePretty
}
>
{t("player.menus.subtitles.SourceChoice")}
</Menu.ChevronLink>
<Menu.ChevronLink
onClick={() =>
router.navigate(
backLink
? "/captions/opensubtitles"
: "/captions/opensubtitlesOverlay",
)
}
rightText={
useSubtitleStore((s) => s.isOpenSubtitles)
? selectedLanguagePretty
: ""
}
>
{t("player.menus.subtitles.OpenSubtitlesChoice")}
</Menu.ChevronLink>
{/* No subtitles available message */}
{!isLoadingExternalSubtitles &&
sourceCaptions.length === 0 &&
externalCaptions.length === 0 && (
<div className="p-4 mt-6 rounded-xl bg-video-context-light bg-opacity-10 text-center">
<div className="text-video-context-type-secondary">
{t("player.menus.subtitles.empty")}
</div>
</div>
)}
{/* Loading external subtitles */}
{isLoadingExternalSubtitles && externalCaptions.length === 0 && (
<div className="p-4 mt-6 rounded-xl bg-video-context-light bg-opacity-10 text-center">
<div className="text-video-context-type-secondary">
{t("player.menus.subtitles.loadingExternal")}
</div>
</div>
)}
{/* Source Subtitles Section */}
{sourceCaptions.length > 0 && (
<>
<div className="text-sm font-semibold text-video-context-type-secondary pt-2 mb-2">
{t("player.menus.subtitles.SourceChoice")}
</div>
{sourceList.length > 0 ? (
sourceList.map(renderSubtitleOption)
) : (
<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>
</FileDropHandler>
</>

View file

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

View file

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

View file

@ -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<SourceSlice> = (set, get) => ({
qualities: [],
audioTracks: [],
captionList: [],
isLoadingExternalSubtitles: false,
currentQuality: null,
currentAudioTrack: null,
status: playerStatus.IDLE,
@ -258,6 +260,10 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (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<SourceSlice> = (set, get) => ({
}
} catch (error) {
console.error("Failed to scrape external subtitles:", error);
} finally {
set((s) => {
s.isLoadingExternalSubtitles = false;
});
}
},
});