diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index a7bad320..064b78a0 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -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(null); + const [chosenLanguage, setChosenLanguage] = useState(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 }) { - + {/* This is used by the captions shortcut in bottomControls of player */} - + @@ -106,6 +114,18 @@ function SettingsOverlay({ id }: { id: string }) { + + + {chosenLanguage && ( + + )} + + diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index 5d875e3b..c9bb969d 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -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 = {}; + + 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) { 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 ( - startDownload(v.id)} - onDoubleClick={handleDoubleClick} - flag - subtitleUrl={v.url} - subtitleType={v.type} - subtitleSource={v.source} - subtitleEncoding={v.encoding} - isHearingImpaired={v.isHearingImpaired} - > - {v.languageName} - - ); - }; - return ( <>
@@ -570,36 +547,30 @@ export function CaptionsView({
)} - {/* Source Subtitles Section */} - {sourceCaptions.length > 0 && ( - <> -
- {t("player.menus.subtitles.SourceChoice")} -
- {sourceList.length > 0 ? ( - sourceList.map(renderSubtitleOption) - ) : ( -
- {t("player.menus.subtitles.notFound")} -
- )} - - )} - - {/* External Subtitles Section */} - {externalCaptions.length > 0 && ( - <> -
- {t("player.menus.subtitles.OpenSubtitlesChoice")} -
- {externalList.length > 0 ? ( - externalList.map(renderSubtitleOption) - ) : ( -
- {t("player.menus.subtitles.notFound")} -
- )} - + {/* Language selection */} + {groupedCaptions.length > 0 ? ( + groupedCaptions.map( + ({ language, languageName, captions: captionsForLang }) => ( + { + onChooseLanguage?.(language); + router.navigate("/captions/languages"); + }} + > + + + {languageName} + + + ), + ) + ) : ( +
+ {t("player.menus.subtitles.notFound")} +
)} {/* Loading indicator for external subtitles while source exists */} diff --git a/src/components/player/atoms/settings/LanguageSelectionView.tsx b/src/components/player/atoms/settings/LanguageSelectionView.tsx new file mode 100644 index 00000000..14e3c1f0 --- /dev/null +++ b/src/components/player/atoms/settings/LanguageSelectionView.tsx @@ -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 = {}; + + 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 ( + <> + router.navigate("/captions")}> + {t("player.menus.subtitles.selectLanguage")} + + + {groupedCaptions.length > 0 ? ( + groupedCaptions.map( + ({ language, languageName, captions: captionsForLang }) => ( + { + onChoose?.(language); + router.navigate("/captions/languages/language"); + }} + > + + + {languageName} + + + ), + ) + ) : ( +
+ {t("player.menus.subtitles.notFound")} +
+ )} +
+ {/* Loading indicator */} + {isLoadingExternalSubtitles && ( +
+ {t("player.menus.subtitles.loadingExternal") || + "Loading external subtitles..."} +
+ )} + + ); +} diff --git a/src/components/player/atoms/settings/LanguageSubtitlesView.tsx b/src/components/player/atoms/settings/LanguageSubtitlesView.tsx new file mode 100644 index 00000000..cd1656a7 --- /dev/null +++ b/src/components/player/atoms/settings/LanguageSubtitlesView.tsx @@ -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 ( + startDownload(v.id)} + onDoubleClick={handleDoubleClick} + flag + subtitleUrl={v.url} + subtitleType={v.type} + subtitleSource={v.source} + subtitleEncoding={v.encoding} + isHearingImpaired={v.isHearingImpaired} + > + {v.languageName} + + ); + }; + + return ( + <> + router.navigate("/captions")}> + + + {languageName} + + + + + {/* Language subtitles */} + {languageCaptions.length > 0 ? ( + languageCaptions.map((caption) => ( +
+ {renderSubtitleOption({ + ...caption, + languageName, + })} +
+ )) + ) : ( +
+ {t("player.menus.subtitles.notFound")} +
+ )} + + {/* Loading indicator */} + {isLoadingExternalSubtitles && ( +
+ {t("player.menus.subtitles.loadingExternal") || + "Loading external subtitles..."} +
+ )} +
+ + ); +} diff --git a/src/components/player/internals/ContextMenu/Links.tsx b/src/components/player/internals/ContextMenu/Links.tsx index 7d53e264..75ae14bb 100644 --- a/src/components/player/internals/ContextMenu/Links.tsx +++ b/src/components/player/internals/ContextMenu/Links.tsx @@ -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 = {props.rightText}; + const rightContent = ( + + {props.selected ? ( + + ) : ( + props.rightText + )} + + + ); + return (