mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-17 21:31:27 +00:00
Merge branch 'captions-revamp' into production
This commit is contained in:
commit
e912d2d157
5 changed files with 378 additions and 118 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";
|
||||
|
|
@ -11,13 +10,13 @@ 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 {
|
||||
captionIsVisible,
|
||||
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";
|
||||
|
|
@ -298,32 +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 [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 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(
|
||||
|
|
@ -342,28 +342,56 @@ export function CaptionsView({
|
|||
[captions],
|
||||
);
|
||||
|
||||
// Filter lists based on search query
|
||||
const sourceList = useSubtitleList(sourceCaptions, searchQuery);
|
||||
const externalList = useSubtitleList(externalCaptions, searchQuery);
|
||||
// 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;
|
||||
|
|
@ -391,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>
|
||||
|
|
@ -552,11 +527,6 @@ export function CaptionsView({
|
|||
|
||||
<div className="h-1" />
|
||||
|
||||
{/* Search input */}
|
||||
{(sourceCaptions.length || externalCaptions.length) > 0 && (
|
||||
<Input value={searchQuery} onInput={setSearchQuery} />
|
||||
)}
|
||||
|
||||
{/* No subtitles available message */}
|
||||
{!isLoadingExternalSubtitles &&
|
||||
sourceCaptions.length === 0 &&
|
||||
|
|
@ -577,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