Refactor captions selection to group by language

Captions selection UI now groups subtitles by language, allowing users to select a language first and then choose a specific subtitle. Added LanguageSelectionView and LanguageSubtitlesView components, updated SettingsOverlay and CaptionsView to support the new flow, and enhanced ChevronLink to show selection state. This improves usability for users with multiple subtitle options per language.
This commit is contained in:
Pas 2025-12-05 22:50:04 -07:00
parent 862df50885
commit 685deb6d0e
5 changed files with 378 additions and 111 deletions

View file

@ -18,6 +18,7 @@ import { AudioView } from "./settings/AudioView";
import { CaptionSettingsView } from "./settings/CaptionSettingsView";
import { CaptionsView } from "./settings/CaptionsView";
import { DownloadRoutes } from "./settings/Downloads";
import { LanguageSubtitlesView } from "./settings/LanguageSubtitlesView";
import { PlaybackSettingsView } from "./settings/PlaybackSettingsView";
import { QualityView } from "./settings/QualityView";
import { SettingsMenu } from "./settings/SettingsMenu";
@ -26,15 +27,18 @@ import { WatchPartyView } from "./settings/WatchPartyView";
function SettingsOverlay({ id }: { id: string }) {
const [chosenSourceId, setChosenSourceId] = useState<string | null>(null);
const [chosenLanguage, setChosenLanguage] = useState<string | null>(null);
const router = useOverlayRouter(id);
// reset source id when going to home or closing overlay
// reset source id and language when going to home or closing overlay
useEffect(() => {
if (!router.isRouterActive) {
setChosenSourceId(null);
setChosenLanguage(null);
}
if (router.route === "/") {
setChosenSourceId(null);
setChosenLanguage(null);
}
}, [router.isRouterActive, router.route]);
@ -56,13 +60,17 @@ function SettingsOverlay({ id }: { id: string }) {
</OverlayPage>
<OverlayPage id={id} path="/captions" width={343} height={452}>
<Menu.CardWithScrollable>
<CaptionsView id={id} backLink />
<CaptionsView
id={id}
backLink
onChooseLanguage={setChosenLanguage}
/>
</Menu.CardWithScrollable>
</OverlayPage>
{/* This is used by the captions shortcut in bottomControls of player */}
<OverlayPage id={id} path="/captionsOverlay" width={343} height={452}>
<Menu.CardWithScrollable>
<CaptionsView id={id} />
<CaptionsView id={id} onChooseLanguage={setChosenLanguage} />
</Menu.CardWithScrollable>
</OverlayPage>
<OverlayPage id={id} path="/captions/settings" width={343} height={452}>
@ -106,6 +114,18 @@ function SettingsOverlay({ id }: { id: string }) {
<TranscriptView id={id} />
</Menu.CardWithScrollable>
</OverlayPage>
<OverlayPage
id={id}
path="/captions/languages"
width={343}
height={452}
>
<Menu.CardWithScrollable>
{chosenLanguage && (
<LanguageSubtitlesView id={id} language={chosenLanguage} />
)}
</Menu.CardWithScrollable>
</OverlayPage>
<DownloadRoutes id={id} />
<OverlayPage id={id} path="/watchparty" width={343} height={455}>
<Menu.CardWithScrollable>

View file

@ -2,7 +2,6 @@ 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";
@ -17,6 +16,7 @@ import {
parseSubtitles,
} from "@/components/player/utils/captions";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { useLanguageStore } from "@/stores/language";
import { CaptionListItem } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { useSubtitleStore } from "@/stores/subtitles";
@ -297,31 +297,33 @@ export function PasteCaptionOption(props: { selected?: boolean }) {
);
}
export interface CaptionsViewProps {
id: string;
backLink?: boolean;
onChooseLanguage?: (language: string) => void;
}
export function CaptionsView({
id,
backLink,
}: {
id: string;
backLink?: true;
}) {
onChooseLanguage,
}: CaptionsViewProps) {
const { t } = useTranslation();
const router = useOverlayRouter(id);
const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id);
const { disable, selectCaptionById } = useCaptions();
const { disable } = useCaptions();
const [dragging, setDragging] = useState(false);
const setCaption = usePlayerStore((s) => s.setCaption);
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 selectedLanguage = 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);
const appLanguage = useLanguageStore((s) => s.language);
// Get combined caption list
const captions = useMemo(
@ -340,28 +342,56 @@ export function CaptionsView({
[captions],
);
// Filter lists based on search query
const sourceList = useSubtitleList(sourceCaptions, "");
const externalList = useSubtitleList(externalCaptions, "");
// Group captions by language
const groupedCaptions = useMemo(() => {
const allCaptions = [...sourceCaptions, ...externalCaptions];
const groups: Record<string, typeof allCaptions> = {};
allCaptions.forEach((caption) => {
const lang = caption.language;
if (!groups[lang]) {
groups[lang] = [];
}
groups[lang].push(caption);
});
// Sort languages
const sortedGroups: Array<{
language: string;
captions: typeof allCaptions;
languageName: string;
}> = [];
Object.entries(groups).forEach(([lang, captionsForLang]) => {
const languageName =
getPrettyLanguageNameFromLocale(lang) ||
t("player.menus.subtitles.unknownLanguage");
sortedGroups.push({
language: lang,
captions: captionsForLang,
languageName,
});
});
// Sort with app language first, then alphabetically
return sortedGroups.sort((a, b) => {
// App language always comes first
if (a.language === appLanguage) return -1;
if (b.language === appLanguage) return 1;
// Then sort alphabetically
return a.languageName.localeCompare(b.languageName);
});
}, [sourceCaptions, externalCaptions, t, appLanguage]);
// Get current subtitle text preview
const currentSubtitleText = useMemo(() => {
if (!srtData || !selectedCaptionId) return null;
const parsedCaptions = parseSubtitles(srtData, language);
const parsedCaptions = parseSubtitles(srtData, selectedLanguage);
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],
);
}, [srtData, selectedLanguage, delay, videoTime, selectedCaptionId]);
function onDrop(event: DragEvent<HTMLDivElement>) {
const files = event.dataTransfer.files;
@ -389,59 +419,6 @@ export function CaptionsView({
reader.readAsText(firstFile);
}
// Render subtitle option
const renderSubtitleOption = (
v: CaptionListItem & { languageName: string },
) => {
const handleDoubleClick = async () => {
const copyData = {
id: v.id,
url: v.url,
language: v.language,
type: v.type,
hasCorsRestrictions: v.needsProxy,
opensubtitles: v.opensubtitles,
display: v.display,
media: v.media,
isHearingImpaired: v.isHearingImpaired,
source: v.source,
encoding: v.encoding,
delay,
};
try {
await navigator.clipboard.writeText(JSON.stringify(copyData));
// Could add a toast notification here if needed
} catch (err) {
console.error("Failed to copy subtitle data:", err);
}
};
return (
<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)}
onDoubleClick={handleDoubleClick}
flag
subtitleUrl={v.url}
subtitleType={v.type}
subtitleSource={v.source}
subtitleEncoding={v.encoding}
isHearingImpaired={v.isHearingImpaired}
>
{v.languageName}
</CaptionOption>
);
};
return (
<>
<div>
@ -570,36 +547,30 @@ export function CaptionsView({
</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>
)}
</>
{/* Language selection */}
{groupedCaptions.length > 0 ? (
groupedCaptions.map(
({ language, languageName, captions: captionsForLang }) => (
<Menu.ChevronLink
key={language}
selected={selectedLanguage === language}
rightText={captionsForLang.length.toString()}
onClick={() => {
onChooseLanguage?.(language);
router.navigate("/captions/languages");
}}
>
<span className="flex items-center">
<FlagIcon langCode={language} />
<span className="ml-3">{languageName}</span>
</span>
</Menu.ChevronLink>
),
)
) : (
<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 */}

View file

@ -0,0 +1,109 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { FlagIcon } from "@/components/FlagIcon";
import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePlayerStore } from "@/stores/player/store";
import { getPrettyLanguageNameFromLocale } from "@/utils/language";
export interface LanguageSelectionViewProps {
id: string;
onChoose?: (language: string) => void;
}
export function LanguageSelectionView({
id,
onChoose,
}: LanguageSelectionViewProps) {
const { t } = useTranslation();
const router = useOverlayRouter(id);
// Get all captions
const captionList = usePlayerStore((s) => s.captionList);
const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList);
const isLoadingExternalSubtitles = usePlayerStore(
(s) => s.isLoadingExternalSubtitles,
);
// Get combined caption list
const captions = useMemo(
() =>
captionList.length !== 0 ? captionList : (getHlsCaptionList?.() ?? []),
[captionList, getHlsCaptionList],
);
// Group captions by language
const groupedCaptions = useMemo(() => {
const groups: Record<string, typeof captions> = {};
captions.forEach((caption) => {
const lang = caption.language;
if (!groups[lang]) {
groups[lang] = [];
}
groups[lang].push(caption);
});
// Sort languages
const sortedGroups: Array<{
language: string;
captions: typeof captions;
languageName: string;
}> = [];
Object.entries(groups).forEach(([lang, captionsForLang]) => {
const languageName =
getPrettyLanguageNameFromLocale(lang) ||
t("player.menus.subtitles.unknownLanguage");
sortedGroups.push({
language: lang,
captions: captionsForLang,
languageName,
});
});
return sortedGroups.sort((a, b) =>
a.languageName.localeCompare(b.languageName),
);
}, [captions, t]);
return (
<>
<Menu.BackLink onClick={() => router.navigate("/captions")}>
{t("player.menus.subtitles.selectLanguage")}
</Menu.BackLink>
<Menu.Section className="pb-4">
{groupedCaptions.length > 0 ? (
groupedCaptions.map(
({ language, languageName, captions: captionsForLang }) => (
<Menu.ChevronLink
key={language}
rightText={captionsForLang.length.toString()}
onClick={() => {
onChoose?.(language);
router.navigate("/captions/languages/language");
}}
>
<span className="flex items-center">
<FlagIcon langCode={language} />
<span className="ml-3">{languageName}</span>
</span>
</Menu.ChevronLink>
),
)
) : (
<div className="text-center text-video-context-type-secondary py-2">
{t("player.menus.subtitles.notFound")}
</div>
)}
</Menu.Section>
{/* Loading indicator */}
{isLoadingExternalSubtitles && (
<div className="text-center text-video-context-type-secondary py-4">
{t("player.menus.subtitles.loadingExternal") ||
"Loading external subtitles..."}
</div>
)}
</>
);
}

View file

@ -0,0 +1,153 @@
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 { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { CaptionListItem } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { getPrettyLanguageNameFromLocale } from "@/utils/language";
import { CaptionOption } from "./CaptionsView";
export interface LanguageSubtitlesViewProps {
id: string;
language: string;
}
export function LanguageSubtitlesView({
id,
language,
}: LanguageSubtitlesViewProps) {
const { t } = useTranslation();
const router = useOverlayRouter(id);
const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id);
const { selectCaptionById } = useCaptions();
const [currentlyDownloading, setCurrentlyDownloading] = useState<
string | null
>(null);
const captionList = usePlayerStore((s) => s.captionList);
const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList);
const isLoadingExternalSubtitles = usePlayerStore(
(s) => s.isLoadingExternalSubtitles,
);
// Get combined caption list
const captions = useMemo(
() =>
captionList.length !== 0 ? captionList : (getHlsCaptionList?.() ?? []),
[captionList, getHlsCaptionList],
);
// Filter captions for this specific language
const languageCaptions = useMemo(
() => captions.filter((caption) => caption.language === language),
[captions, language],
);
// Download handler
const [downloadReq, startDownload] = useAsyncFn(
async (captionId: string) => {
setCurrentlyDownloading(captionId);
return selectCaptionById(captionId);
},
[selectCaptionById, setCurrentlyDownloading],
);
const languageName = useMemo(() => {
return (
getPrettyLanguageNameFromLocale(language) ||
t("player.menus.subtitles.unknownLanguage")
);
}, [language, t]);
const renderSubtitleOption = (
v: CaptionListItem & { languageName: string },
) => {
const handleDoubleClick = async () => {
const copyData = {
id: v.id,
url: v.url,
language: v.language,
type: v.type,
hasCorsRestrictions: v.needsProxy,
opensubtitles: v.opensubtitles,
display: v.display,
media: v.media,
isHearingImpaired: v.isHearingImpaired,
source: v.source,
encoding: v.encoding,
delay: 0, // Will be set from current delay if needed
};
try {
await navigator.clipboard.writeText(JSON.stringify(copyData));
} catch (err) {
console.error("Failed to copy subtitle data:", err);
}
};
return (
<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)}
onDoubleClick={handleDoubleClick}
flag
subtitleUrl={v.url}
subtitleType={v.type}
subtitleSource={v.source}
subtitleEncoding={v.encoding}
isHearingImpaired={v.isHearingImpaired}
>
{v.languageName}
</CaptionOption>
);
};
return (
<>
<Menu.BackLink onClick={() => router.navigate("/captions")}>
<span className="flex items-center">
<FlagIcon langCode={language} />
<span className="ml-3">{languageName}</span>
</span>
</Menu.BackLink>
<Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3">
{/* Language subtitles */}
{languageCaptions.length > 0 ? (
languageCaptions.map((caption) => (
<div key={caption.id}>
{renderSubtitleOption({
...caption,
languageName,
})}
</div>
))
) : (
<div className="text-center text-video-context-type-secondary py-2">
{t("player.menus.subtitles.notFound")}
</div>
)}
{/* Loading indicator */}
{isLoadingExternalSubtitles && (
<div className="text-center text-video-context-type-secondary py-4 mt-2">
{t("player.menus.subtitles.loadingExternal") ||
"Loading external subtitles..."}
</div>
)}
</Menu.ScrollToActiveSection>
</>
);
}

View file

@ -138,13 +138,27 @@ export function Link(props: {
export function ChevronLink(props: {
rightText?: string;
selected?: boolean;
onClick?: () => void;
children?: ReactNode;
active?: boolean;
box?: boolean;
disabled?: boolean;
}) {
const rightContent = <Chevron>{props.rightText}</Chevron>;
const rightContent = (
<span className="text-white flex items-center font-medium">
{props.selected ? (
<Icon
icon={Icons.CIRCLE_CHECK}
className="text-xl text-video-context-type-accent"
/>
) : (
props.rightText
)}
<Icon className="text-xl ml-1 -mr-1.5" icon={Icons.CHEVRON_RIGHT} />
</span>
);
return (
<Link
onClick={props.onClick}