import classNames from "classnames"; import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useAsync } from "react-use"; import { getMetaFromId } from "@/backend/metadata/getmeta"; import { MWMediaType, MWSeasonMeta } from "@/backend/metadata/types/mw"; import { Icon, Icons } from "@/components/Icon"; import { ProgressRing } from "@/components/layout/ProgressRing"; import { OverlayAnchor } from "@/components/overlays/OverlayAnchor"; import { Overlay } from "@/components/overlays/OverlayDisplay"; import { OverlayPage } from "@/components/overlays/OverlayPage"; import { OverlayRouter } from "@/components/overlays/OverlayRouter"; import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; import { VideoPlayerButton } from "@/components/player/internals/Button"; import { Menu } from "@/components/player/internals/ContextMenu"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { useBookmarkStore } from "@/stores/bookmarks"; import { PlayerMeta } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; import { useProgressStore } from "@/stores/progress"; import { hasAired } from "../utils/aired"; function CenteredText(props: { children: React.ReactNode }) { return (
{props.children}
); } interface EpisodeItemProps { episode: any; isActive: boolean; isAired: boolean; isWatched: boolean; isFavorited: boolean; percentage: number; episodeProgress?: any; onPlay: (episodeId: string) => void; onToggleWatch: (episodeId: string, event: React.MouseEvent) => void; onToggleFavorite: (episodeId: string, event: React.MouseEvent) => void; onToggleExpansion?: (episodeId: string, event: React.MouseEvent) => void; expandedEpisodes?: { [key: string]: boolean }; truncatedEpisodes?: { [key: string]: boolean }; descriptionRefs?: React.MutableRefObject<{ [key: string]: HTMLParagraphElement | null; }>; forceCompactEpisodeView?: boolean; seasonNumber?: number; } function EpisodeItem({ episode, isActive, isAired, isWatched, isFavorited, percentage, episodeProgress, onPlay, onToggleWatch, onToggleFavorite, onToggleExpansion, expandedEpisodes = {}, truncatedEpisodes = {}, descriptionRefs, forceCompactEpisodeView = false, seasonNumber, }: EpisodeItemProps) { const { t } = useTranslation(); return (
{/* Extra small screens - Simple vertical list with no thumbnails */}
onPlay(episode.id)} active={isActive} clickable={isAired} rightSide={
{isAired && ( <> {!isActive && ( )} )} {episodeProgress && ( )}
} >
{seasonNumber ? `S${seasonNumber}E${episode.number}` : `E${episode.number}`} {episode.title}
{/* Small screens - Vertical list with thumbnails to the left */}
onPlay(episode.id)} className={classNames( "hidden sm:flex lg:hidden w-full rounded-lg overflow-hidden transition-all duration-200 relative cursor-pointer", forceCompactEpisodeView ? "!hidden" : "", isActive ? "bg-video-context-hoverColor/50" : "hover:bg-video-context-hoverColor/50", !isAired ? "opacity-50" : "", )} > {/* Thumbnail */}
{episode.still_path ? ( {episode.title} ) : (
)} {/* Episode Number Badge */}
{seasonNumber ? `S${seasonNumber}E${episode.number}` : `E${episode.number}`} {!isAired && ( {episode.air_date ? `(${t("details.airs")} - ${new Date(episode.air_date).toLocaleDateString()})` : `(${t("media.unreleased")})`} )}
{/* Mark as watched and favorite buttons */} {isAired && (
{!isActive && ( )}
)}
{/* Content */}

{episode.title}

{episode.overview && (

{ if (descriptionRefs) { descriptionRefs.current[`medium-${episode.id}`] = el; } }} className={classNames( "text-sm text-white/80 mt-1.5 transition-all duration-200", !expandedEpisodes[`medium-${episode.id}`] ? "line-clamp-2" : "max-h-[200px] overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent pr-2", )} > {episode.overview}

{!expandedEpisodes[`medium-${episode.id}`] && truncatedEpisodes[`medium-${episode.id}`] && ( )} {expandedEpisodes[`medium-${episode.id}`] && ( )}
)}
{/* Progress indicator */} {percentage > 0 && (
)}
{/* Large screens - Horizontal cards with thumbnails above title */}
onPlay(episode.id)} className={classNames( "hidden lg:block flex-shrink-0 transition-all duration-200 relative cursor-pointer rounded-lg overflow-hidden", forceCompactEpisodeView ? "!hidden" : "", isActive ? "bg-video-context-hoverColor/50" : "hover:bg-video-context-hoverColor/50", !isAired ? "opacity-50" : "hover:scale-95", expandedEpisodes[`large-${episode.id}`] ? "w-[32rem]" : "w-64", "h-[280px]", // Fixed height for all states )} > {/* Thumbnail */} {!expandedEpisodes[`large-${episode.id}`] && (
{episode.still_path ? ( {episode.title} ) : (
)} {/* Episode Number Badge */}
{seasonNumber ? `S${seasonNumber}E${episode.number}` : `E${episode.number}`} {!isAired && ( {episode.air_date ? `(${t("details.airs")} - ${new Date(episode.air_date).toLocaleDateString()})` : `(${t("media.unreleased")})`} )}
{/* Mark as watched and favorite buttons */} {isAired && (
{!isActive && ( )}
)}
)} {/* Content */}

{episode.title}

{expandedEpisodes[`large-${episode.id}`] && isAired && (
{!isActive && ( )}
)}
{episode.overview && (

{ if (descriptionRefs) { descriptionRefs.current[`large-${episode.id}`] = el; } }} className={classNames( "text-sm text-white/80 mt-1.5 transition-all duration-200", !expandedEpisodes[`large-${episode.id}`] ? "line-clamp-2" : "max-h-[200px] overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent pr-2", )} > {episode.overview}

{!expandedEpisodes[`large-${episode.id}`] && truncatedEpisodes[`large-${episode.id}`] && ( )} {expandedEpisodes[`large-${episode.id}`] && ( )}
)}
{/* Progress indicator */} {percentage > 0 && (
)}
); } function useSeasonData(mediaId: string, seasonId: string) { const [seasons, setSeason] = useState(null); const state = useAsync(async () => { const data = await getMetaFromId(MWMediaType.SERIES, mediaId, seasonId); if (data?.meta.type !== MWMediaType.SERIES) return null; setSeason(data.meta.seasons); return { season: data.meta.seasonData, fullData: data, }; }, [mediaId, seasonId]); return [state, seasons] as const; } function SeasonsView({ selectedSeason, setSeason, }: { selectedSeason: string; setSeason: (id: string) => void; }) { const { t } = useTranslation(); const meta = usePlayerStore((s) => s.meta); const [loadingState, seasons] = useSeasonData( meta?.tmdbId ?? "", selectedSeason, ); const getFavoriteEpisodes = useBookmarkStore((s) => s.getFavoriteEpisodes); const favoriteEpisodes = meta?.tmdbId ? getFavoriteEpisodes(meta.tmdbId) : []; let content: ReactNode = null; if (seasons) { content = ( {/* Favorites section */} {favoriteEpisodes.length > 0 && ( setSeason("favorites")} > {t("player.menus.episodes.favorites")} ({favoriteEpisodes.length}) )} {seasons?.map((season) => { return ( setSeason(season.id)} > {season.title} ); })} ); } else if (loadingState.error) content = ( {t("player.menus.episodes.loadingError")} ); else if (loadingState.loading) content = ( {t("player.menus.episodes.loadingList")} ); return ( {meta?.title ?? t("player.menus.episodes.loadingTitle")} {content} ); } export function EpisodesView({ id, selectedSeason, goBack, onChange, }: { id: string; selectedSeason: string; goBack?: () => void; onChange?: (meta: PlayerMeta) => void; }) { const { t } = useTranslation(); const router = useOverlayRouter(id); const { setPlayerMeta } = usePlayerMeta(); const meta = usePlayerStore((s) => s.meta); const [loadingState, seasons] = useSeasonData( meta?.tmdbId ?? "", selectedSeason, ); const progress = useProgressStore(); const updateItem = useProgressStore((s) => s.updateItem); const getFavoriteEpisodes = useBookmarkStore((s) => s.getFavoriteEpisodes); const favoriteEpisodes = meta?.tmdbId ? getFavoriteEpisodes(meta.tmdbId) : []; const bookmarks = useBookmarkStore((s) => s.bookmarks); // Load all seasons for favorites view const [allSeasonsLoading, setAllSeasonsLoading] = useState(false); const [allSeasonsData, setAllSeasonsData] = useState([]); useEffect(() => { if (selectedSeason === "favorites" && meta?.tmdbId && seasons) { setAllSeasonsLoading(true); const loadAllSeasons = async () => { const seasonPromises = seasons.map(async (season) => { try { const data = await getMetaFromId( MWMediaType.SERIES, meta.tmdbId, season.id, ); return data?.meta.type === MWMediaType.SERIES ? data.meta.seasonData : null; } catch (error) { console.error(`Failed to load season ${season.id}:`, error); return null; } }); const results = await Promise.all(seasonPromises); setAllSeasonsData(results.filter(Boolean)); setAllSeasonsLoading(false); }; loadAllSeasons(); } }, [selectedSeason, meta?.tmdbId, seasons]); const carouselRef = useRef(null); const activeEpisodeRef = useRef(null); const [expandedEpisodes, setExpandedEpisodes] = useState<{ [key: string]: boolean; }>({}); const [truncatedEpisodes, setTruncatedEpisodes] = useState<{ [key: string]: boolean; }>({}); const descriptionRefs = useRef<{ [key: string]: HTMLParagraphElement | null; }>({}); const forceCompactEpisodeView = usePreferencesStore( (s) => s.forceCompactEpisodeView, ); const isTextTruncated = (element: HTMLElement | null) => { if (!element) return false; return element.scrollHeight > element.clientHeight; }; // Check truncation after render and when expanded state changes useEffect(() => { const checkTruncation = () => { const newTruncatedState: { [key: string]: boolean } = {}; if (!loadingState.value) return; loadingState.value.season.episodes.forEach((ep) => { // Check medium view if (!expandedEpisodes[`medium-${ep.id}`]) { const mediumElement = descriptionRefs.current[`medium-${ep.id}`]; newTruncatedState[`medium-${ep.id}`] = isTextTruncated(mediumElement); } // Check large view if (!expandedEpisodes[`large-${ep.id}`]) { const largeElement = descriptionRefs.current[`large-${ep.id}`]; newTruncatedState[`large-${ep.id}`] = isTextTruncated(largeElement); } }); setTruncatedEpisodes(newTruncatedState); }; // Initial check checkTruncation(); // Check after a short delay to ensure content is rendered const timeoutId = setTimeout(checkTruncation, 250); // Also check when window is resized const handleResize = () => { checkTruncation(); }; window.addEventListener("resize", handleResize); return () => { clearTimeout(timeoutId); window.removeEventListener("resize", handleResize); }; }, [loadingState.value, expandedEpisodes]); const toggleEpisodeExpansion = ( episodeId: string, event: React.MouseEvent, ) => { event.stopPropagation(); setExpandedEpisodes((prev) => ({ ...prev, [episodeId]: !prev[episodeId], })); }; const playEpisode = useCallback( (episodeId: string) => { const oldMetaCopy = { ...meta }; if (loadingState.value) { const newData = setPlayerMeta(loadingState.value.fullData, episodeId); window.parent.postMessage( { type: "episodeChanged", episodeNumber: newData?.episode?.number, seasonNumber: newData?.season?.number, tmdbId: oldMetaCopy?.tmdbId, imdbId: oldMetaCopy?.imdbId, oldEpisodeNumber: oldMetaCopy?.episode?.number, oldSeasonNumber: oldMetaCopy?.season?.number, }, "*", ); if (newData) onChange?.(newData); } // prevent router clear here, otherwise its done double // player already switches route after meta change router.close(true); }, [setPlayerMeta, loadingState, router, onChange, meta], ); const toggleWatchStatus = useCallback( (episodeId: string, event: React.MouseEvent) => { event.stopPropagation(); if (loadingState.value && meta?.tmdbId) { const episode = loadingState.value.season.episodes.find( (ep) => ep.id === episodeId, ); if (episode) { // Check if the episode is already watched const episodeProgress = progress.items[meta.tmdbId]?.episodes?.[episodeId]; const percentage = episodeProgress ? (episodeProgress.progress.watched / episodeProgress.progress.duration) * 100 : 0; // If watched (>90%), reset to 0%, otherwise set to 100% const isWatched = percentage > 90; updateItem({ meta: { tmdbId: meta.tmdbId, title: meta.title || "", type: "show", releaseYear: meta.releaseYear, poster: meta.poster, episode: { tmdbId: episodeId, number: episode.number, title: episode.title || "", }, season: { tmdbId: selectedSeason, number: loadingState.value.season.number, title: loadingState.value.season.title || "", }, }, progress: { watched: isWatched ? 0 : 60, duration: 60, }, }); } } }, [loadingState, meta, selectedSeason, updateItem, progress.items], ); const toggleFavoriteEpisode = useBookmarkStore( (s) => s.toggleFavoriteEpisode, ); const toggleFavoriteStatus = useCallback( (episodeId: string, event: React.MouseEvent) => { event.stopPropagation(); if (meta?.tmdbId) { toggleFavoriteEpisode(meta.tmdbId, episodeId, { title: meta.title || "", poster: meta.poster, year: meta.releaseYear, }); } }, [ meta?.tmdbId, meta?.title, meta?.poster, meta?.releaseYear, toggleFavoriteEpisode, ], ); const handleScroll = (direction: "left" | "right") => { if (!carouselRef.current) return; const cardWidth = 256; // w-64 in pixels const cardSpacing = 16; // space-x-4 in pixels const scrollAmount = (cardWidth + cardSpacing) * 2; const newScrollPosition = carouselRef.current.scrollLeft + (direction === "left" ? -scrollAmount : scrollAmount); carouselRef.current.scrollTo({ left: newScrollPosition, behavior: "smooth", }); }; useEffect(() => { if (activeEpisodeRef.current) { // horizontal scroll if (window.innerWidth >= 1024 && carouselRef.current) { const containerLeft = carouselRef.current.getBoundingClientRect().left; const containerWidth = carouselRef.current.clientWidth; const elementLeft = activeEpisodeRef.current.getBoundingClientRect().left; const elementWidth = activeEpisodeRef.current.clientWidth; // Calculate center const scrollPosition = elementLeft - containerLeft - containerWidth / 2 + elementWidth / 2; carouselRef.current.scrollLeft += scrollPosition; } else { // vertical scroll activeEpisodeRef.current.scrollIntoView({ behavior: "smooth", block: "center", }); } } }, [loadingState.value]); if (!meta?.tmdbId) return null; let content: ReactNode = null; if (loadingState.error) content = ( {t("player.menus.episodes.loadingError")} ); else if (loadingState.loading) content = ( {t("player.menus.episodes.loadingList")} ); else if (selectedSeason === "favorites") { // Handle favorites view - show actual favorite episodes if (favoriteEpisodes.length === 0) { content = (

{t("player.menus.episodes.noFavorites")}

); } else if (allSeasonsLoading) { content = ( {t("player.menus.episodes.loadingList")} ); } else { // Get all episodes from all seasons and filter by favorite episode IDs const allEpisodes = allSeasonsData.flatMap((seasonData) => seasonData.episodes.map((ep: any) => ({ ...ep, seasonNumber: seasonData.number, })), ); const favoriteEpisodesData = allEpisodes.filter((ep) => favoriteEpisodes.includes(ep.id), ); if (favoriteEpisodesData.length === 0) { content = (

{t("player.menus.episodes.noFavorites")}

); } else { content = (
= 1024 && !forceCompactEpisodeView, }, forceCompactEpisodeView ? "flex-col space-y-3" : "flex-col lg:flex-row lg:overflow-x-auto space-y-3 sm:space-y-4 lg:space-y-0 lg:space-x-4 lg:px-12", )} style={{ scrollbarWidth: "none", msOverflowStyle: "none", }} > {favoriteEpisodesData.map((ep) => { const episodeProgress = progress.items[meta?.tmdbId ?? ""]?.episodes?.[ep.id]; const percentage = episodeProgress ? (episodeProgress.progress.watched / episodeProgress.progress.duration) * 100 : 0; const isWatched = percentage > 90; const isAired = hasAired(ep.air_date); const isActive = ep.id === meta?.episode?.tmdbId; const isFavorited = meta?.tmdbId ? (bookmarks[meta.tmdbId]?.favoriteEpisodes?.includes( ep.id, ) ?? false) : false; return ( ); })}
); } } } else if (loadingState.value) { content = (
{/* Horizontal scroll buttons */}
= 1024 && !forceCompactEpisodeView, }, forceCompactEpisodeView ? "flex-col space-y-3" : "flex-col lg:flex-row lg:overflow-x-auto space-y-3 sm:space-y-4 lg:space-y-0 lg:space-x-4 lg:px-12 ", )} style={{ scrollbarWidth: "none", msOverflowStyle: "none", }} > {loadingState.value.season.episodes.length === 0 ? (

{t("player.menus.episodes.emptyState")}

) : ( loadingState.value.season.episodes.map((ep) => { const episodeProgress = progress.items[meta?.tmdbId]?.episodes?.[ep.id]; const percentage = episodeProgress ? (episodeProgress.progress.watched / episodeProgress.progress.duration) * 100 : 0; const isAired = hasAired(ep.air_date); const isActive = ep.id === meta?.episode?.tmdbId; const isWatched = percentage > 90; const isFavorited = meta?.tmdbId ? (bookmarks[meta.tmdbId]?.favoriteEpisodes?.includes(ep.id) ?? false) : false; return (
); }) )}
{/* Right scroll button */}
); } return ( {selectedSeason === "favorites" ? t("player.menus.episodes.favorites") : loadingState?.value?.season.title || t("player.menus.episodes.loadingTitle")} {content} ); } function EpisodesOverlay({ id, onChange, }: { id: string; onChange?: (meta: PlayerMeta) => void; }) { const router = useOverlayRouter(id); const meta = usePlayerStore((s) => s.meta); const [selectedSeason, setSelectedSeason] = useState(""); const lastActiveState = useRef(false); useEffect(() => { if (lastActiveState.current === router.isRouterActive) return; lastActiveState.current = router.isRouterActive; setSelectedSeason(meta?.season?.tmdbId ?? ""); }, [meta, selectedSeason, setSelectedSeason, router.isRouterActive]); const setSeason = useCallback( (seasonId: string) => { setSelectedSeason(seasonId); router.navigate("/episodes"); }, [router], ); const forceCompactEpisodeView = usePreferencesStore( (s) => s.forceCompactEpisodeView, ); return ( {selectedSeason.length > 0 ? ( router.navigate("/")} onChange={onChange} /> ) : null} ); } interface EpisodesProps { onChange?: (meta: PlayerMeta) => void; } export function EpisodesRouter(props: EpisodesProps) { return ; } export function Episodes(props: { inControl: boolean }) { const { t } = useTranslation(); const router = useOverlayRouter("episodes"); const setHasOpenOverlay = usePlayerStore((s) => s.setHasOpenOverlay); const type = usePlayerStore((s) => s.meta?.type); useEffect(() => { setHasOpenOverlay(router.isRouterActive); }, [setHasOpenOverlay, router.isRouterActive]); if (type !== "show" || !props.inControl) return null; return ( router.open("/episodes")} icon={Icons.EPISODES} > {t("player.menus.episodes.button")} ); }