Merge branch 'captions-revamp' into production

This commit is contained in:
Pas 2025-12-21 21:14:09 -07:00
commit e912d2d157
5 changed files with 378 additions and 118 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";
@ -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 */}

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}