From d4969c05409b1e36ec8bb7c4cff88d13b77d5083 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 4 Jun 2025 14:03:13 -0600 Subject: [PATCH] add expanding episode descriptions --- src/assets/locales/en.json | 4 +- .../overlays/details/EpisodeCarousel.tsx | 163 ++++++++++--- src/components/player/atoms/Episodes.tsx | 227 +++++++++++++++--- tailwind.config.ts | 9 +- 4 files changed, 325 insertions(+), 78 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 4dc35467..20af5563 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -477,7 +477,9 @@ "loadingList": "Loading...", "loadingTitle": "Loading...", "unairedEpisodes": "One or more episodes in this season have been disabled because they haven't been aired yet.", - "seasons": "Seasons" + "seasons": "Seasons", + "showMore": "Show more", + "showLess": "Show less" }, "playback": { "speedLabel": "Playback speed", diff --git a/src/components/overlays/details/EpisodeCarousel.tsx b/src/components/overlays/details/EpisodeCarousel.tsx index c0d30e64..c931eecd 100644 --- a/src/components/overlays/details/EpisodeCarousel.tsx +++ b/src/components/overlays/details/EpisodeCarousel.tsx @@ -23,9 +23,18 @@ export function EpisodeCarousel({ const [showEpisodeMenu, setShowEpisodeMenu] = useState(false); const [customSeason, setCustomSeason] = useState(""); const [customEpisode, setCustomEpisode] = useState(""); + const [expandedEpisodes, setExpandedEpisodes] = useState<{ + [key: number]: boolean; + }>({}); + const [truncatedEpisodes, setTruncatedEpisodes] = useState<{ + [key: number]: boolean; + }>({}); const episodeMenuRef = useRef(null); const carouselRef = useRef(null); const activeEpisodeRef = useRef(null); + const descriptionRefs = useRef<{ + [key: number]: HTMLParagraphElement | null; + }>({}); const handleScroll = (direction: "left" | "right") => { if (!carouselRef.current) return; @@ -140,6 +149,40 @@ export function EpisodeCarousel({ (ep) => ep.season_number === selectedSeason, ); + const toggleEpisodeExpansion = ( + episodeId: number, + event: React.MouseEvent, + ) => { + event.preventDefault(); + setExpandedEpisodes((prev) => ({ + ...prev, + [episodeId]: !prev[episodeId], + })); + }; + + 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: number]: boolean } = {}; + episodes.forEach((episode) => { + if (!expandedEpisodes[episode.id]) { + const element = descriptionRefs.current[episode.id]; + newTruncatedState[episode.id] = isTextTruncated(element); + } + }); + setTruncatedEpisodes(newTruncatedState); + }; + + // Wait for the transition to complete + const timeoutId = setTimeout(checkTruncation, 250); + return () => clearTimeout(timeoutId); + }, [episodes, expandedEpisodes]); + return (
{/* Season Selector */} @@ -252,6 +295,7 @@ export function EpisodeCarousel({ 100 : 0; const isAired = hasAired(episode.air_date); + const isExpanded = expandedEpisodes[episode.id]; return ( {/* Thumbnail */} -
- {episode.still_path ? ( - {episode.name} - ) : ( -
- + {episode.still_path ? ( + {episode.name} -
- )} + ) : ( +
+ +
+ )} - {/* Episode Number Badge */} -
- - {t("media.episodeShort")} - {episode.episode_number} - - {!isAired && ( - - {episode.air_date - ? `(${t("details.airs")} - ${new Date(episode.air_date).toLocaleDateString()})` - : `(${t("media.unreleased")})`} + {/* Episode Number Badge */} +
+ + {t("media.episodeShort")} + {episode.episode_number} + + {!isAired && ( + + {episode.air_date + ? `(${t("details.airs")} - ${new Date(episode.air_date).toLocaleDateString()})` + : `(${t("media.unreleased")})`} + + )} +
+
+ )} + + {/* Content */} +
+
+

+ {episode.name} +

+ {!isExpanded && ( + + {t("media.episodeShort")} + {episode.episode_number} )}
-
- - {/* Content */} -
-

- {episode.name} -

{episode.overview && ( -

- {episode.overview} -

+
+

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

+ {!isExpanded && truncatedEpisodes[episode.id] && ( + + )} + {isExpanded && ( + + )} +
)}
diff --git a/src/components/player/atoms/Episodes.tsx b/src/components/player/atoms/Episodes.tsx index 88e59b8e..ebebe778 100644 --- a/src/components/player/atoms/Episodes.tsx +++ b/src/components/player/atoms/Episodes.tsx @@ -94,7 +94,7 @@ function SeasonsView({ ); } -function EpisodesView({ +export function EpisodesView({ id, selectedSeason, goBack, @@ -113,6 +113,70 @@ function EpisodesView({ const progress = useProgressStore(); 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 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) => { @@ -307,9 +371,44 @@ function EpisodesView({ {ep.title} {ep.overview && ( -

- {ep.overview} -

+
+

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

+ {!expandedEpisodes[`medium-${ep.id}`] && + truncatedEpisodes[`medium-${ep.id}`] && ( + + )} + {expandedEpisodes[`medium-${ep.id}`] && ( + + )} +
)}
@@ -330,54 +429,102 @@ function EpisodesView({
playEpisode(ep.id)} className={classNames( - "hidden lg:block flex-shrink-0 w-64 rounded-lg overflow-hidden transition-all duration-200 relative cursor-pointer", + "hidden lg:block flex-shrink-0 transition-all duration-200 relative cursor-pointer rounded-lg overflow-hidden", isActive ? "bg-video-context-hoverColor/50" : "hover:bg-video-context-hoverColor/50", !isAired ? "opacity-50" : "hover:scale-95", + expandedEpisodes[`large-${ep.id}`] ? "w-[32rem]" : "w-64", + "h-[280px]", // Fixed height for all states )} > {/* Thumbnail */} -
- {ep.still_path ? ( - {ep.title} - ) : ( -
- + {ep.still_path ? ( + {ep.title} -
- )} - - {/* Episode Number Badge */} -
- - E{ep.number} - - {!isAired && ( - - {ep.air_date - ? `(${t("details.airs")} - ${new Date(ep.air_date).toLocaleDateString()})` - : `(${t("media.unreleased")})`} - + ) : ( +
+ +
)} + + {/* Episode Number Badge */} +
+ + E{ep.number} + + {!isAired && ( + + {ep.air_date + ? `(${t("details.airs")} - ${new Date(ep.air_date).toLocaleDateString()})` + : `(${t("media.unreleased")})`} + + )} +
-
+ )} {/* Content */} -
-

- {ep.title} -

+
+
+

+ {ep.title} +

+
{ep.overview && ( -

- {ep.overview} -

+
+

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

+ {!expandedEpisodes[`large-${ep.id}`] && + truncatedEpisodes[`large-${ep.id}`] && ( + + )} + {expandedEpisodes[`large-${ep.id}`] && ( + + )} +
)}
@@ -456,7 +603,7 @@ function EpisodesOverlay({ - + {selectedSeason.length > 0 ? (