mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
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:
parent
862df50885
commit
685deb6d0e
5 changed files with 378 additions and 111 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
109
src/components/player/atoms/settings/LanguageSelectionView.tsx
Normal file
109
src/components/player/atoms/settings/LanguageSelectionView.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
153
src/components/player/atoms/settings/LanguageSubtitlesView.tsx
Normal file
153
src/components/player/atoms/settings/LanguageSubtitlesView.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue