mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
add expanding episode descriptions
This commit is contained in:
parent
ede3613112
commit
d4969c0540
4 changed files with 325 additions and 78 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
const activeEpisodeRef = useRef<HTMLAnchorElement>(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 (
|
||||
<div className="mt-6 md:mt-0">
|
||||
{/* Season Selector */}
|
||||
|
|
@ -252,6 +295,7 @@ export function EpisodeCarousel({
|
|||
100
|
||||
: 0;
|
||||
const isAired = hasAired(episode.air_date);
|
||||
const isExpanded = expandedEpisodes[episode.id];
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
|
@ -259,55 +303,102 @@ export function EpisodeCarousel({
|
|||
to={getEpisodeUrl(episode)}
|
||||
ref={isActive ? activeEpisodeRef : null}
|
||||
className={classNames(
|
||||
"flex-shrink-0 w-52 md:w-64 rounded-lg overflow-hidden transition-all duration-200 relative cursor-pointer hover:scale-95",
|
||||
"flex-shrink-0 transition-all duration-200 relative cursor-pointer hover:scale-95 rounded-lg overflow-hidden",
|
||||
isActive
|
||||
? "bg-video-context-hoverColor/50 hover:bg-white/5"
|
||||
: "hover:bg-white/5",
|
||||
!isAired ? "opacity-50" : "",
|
||||
isExpanded ? "w-[32rem]" : "w-52 md:w-64",
|
||||
"h-[280px]", // Fixed height for all states
|
||||
)}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="relative aspect-video w-full bg-video-context-hoverColor">
|
||||
{episode.still_path ? (
|
||||
<img
|
||||
src={`https://image.tmdb.org/t/p/w300${episode.still_path}`}
|
||||
alt={episode.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-black bg-opacity-50">
|
||||
<Icon
|
||||
icon={Icons.FILM}
|
||||
className="text-video-context-type-main opacity-50 text-3xl"
|
||||
{!isExpanded && (
|
||||
<div className="relative h-[158px] w-full bg-video-context-hoverColor">
|
||||
{episode.still_path ? (
|
||||
<img
|
||||
src={`https://image.tmdb.org/t/p/w300${episode.still_path}`}
|
||||
alt={episode.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-black bg-opacity-50">
|
||||
<Icon
|
||||
icon={Icons.FILM}
|
||||
className="text-video-context-type-main opacity-50 text-3xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Episode Number Badge */}
|
||||
<div className="absolute top-2 left-2 flex items-center space-x-2">
|
||||
<span className="p-0.5 px-2 rounded inline bg-video-context-hoverColor bg-opacity-80 text-video-context-type-main text-sm">
|
||||
{t("media.episodeShort")}
|
||||
{episode.episode_number}
|
||||
</span>
|
||||
{!isAired && (
|
||||
<span className="text-video-context-type-main/70 text-sm">
|
||||
{episode.air_date
|
||||
? `(${t("details.airs")} - ${new Date(episode.air_date).toLocaleDateString()})`
|
||||
: `(${t("media.unreleased")})`}
|
||||
{/* Episode Number Badge */}
|
||||
<div className="absolute top-2 left-2 flex items-center space-x-2">
|
||||
<span className="p-0.5 px-2 rounded inline bg-video-context-hoverColor bg-opacity-80 text-video-context-type-main text-sm">
|
||||
{t("media.episodeShort")}
|
||||
{episode.episode_number}
|
||||
</span>
|
||||
{!isAired && (
|
||||
<span className="text-video-context-type-main/70 text-sm">
|
||||
{episode.air_date
|
||||
? `(${t("details.airs")} - ${new Date(episode.air_date).toLocaleDateString()})`
|
||||
: `(${t("media.unreleased")})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={classNames(
|
||||
"p-3",
|
||||
isExpanded ? "h-full" : "h-[122px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-bold text-white line-clamp-1">
|
||||
{episode.name}
|
||||
</h3>
|
||||
{!isExpanded && (
|
||||
<span className="p-0.5 px-2 rounded inline bg-video-context-hoverColor bg-opacity-80 text-video-context-type-main text-sm">
|
||||
{t("media.episodeShort")}
|
||||
{episode.episode_number}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3">
|
||||
<h3 className="font-bold text-white line-clamp-1">
|
||||
{episode.name}
|
||||
</h3>
|
||||
{episode.overview && (
|
||||
<p className="text-sm text-white/80 mt-1.5 line-clamp-2">
|
||||
{episode.overview}
|
||||
</p>
|
||||
<div className="relative">
|
||||
<p
|
||||
ref={(el) => {
|
||||
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}
|
||||
</p>
|
||||
{!isExpanded && truncatedEpisodes[episode.id] && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => toggleEpisodeExpansion(episode.id, e)}
|
||||
className="text-sm text-white/60 hover:text-white transition-opacity duration-200 opacity-0 animate-fade-in"
|
||||
>
|
||||
{t("player.menus.episodes.showMore")}
|
||||
</button>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => toggleEpisodeExpansion(episode.id, e)}
|
||||
className="mt-2 text-sm text-white/60 hover:text-white transition-opacity duration-200 opacity-0 animate-fade-in"
|
||||
>
|
||||
{t("player.menus.episodes.showLess")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const activeEpisodeRef = useRef<HTMLDivElement>(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}
|
||||
</h3>
|
||||
{ep.overview && (
|
||||
<p className="text-sm text-white/80 mt-1.5 line-clamp-2">
|
||||
{ep.overview}
|
||||
</p>
|
||||
<div className="relative">
|
||||
<p
|
||||
ref={(el) => {
|
||||
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}
|
||||
</p>
|
||||
{!expandedEpisodes[`medium-${ep.id}`] &&
|
||||
truncatedEpisodes[`medium-${ep.id}`] && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) =>
|
||||
toggleEpisodeExpansion(`medium-${ep.id}`, e)
|
||||
}
|
||||
className="text-sm text-white/60 hover:text-white transition-opacity duration-200 opacity-0 animate-fade-in"
|
||||
>
|
||||
{t("player.menus.episodes.showMore")}
|
||||
</button>
|
||||
)}
|
||||
{expandedEpisodes[`medium-${ep.id}`] && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) =>
|
||||
toggleEpisodeExpansion(`medium-${ep.id}`, e)
|
||||
}
|
||||
className="mt-2 text-sm text-white/60 hover:text-white transition-opacity duration-200 opacity-0 animate-fade-in"
|
||||
>
|
||||
{t("player.menus.episodes.showLess")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -330,54 +429,102 @@ function EpisodesView({
|
|||
<div
|
||||
onClick={() => 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 */}
|
||||
<div className="relative aspect-video w-full bg-video-context-hoverColor">
|
||||
{ep.still_path ? (
|
||||
<img
|
||||
src={`https://image.tmdb.org/t/p/w300${ep.still_path}`}
|
||||
alt={ep.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-black bg-opacity-50">
|
||||
<Icon
|
||||
icon={Icons.FILM}
|
||||
className="text-video-context-type-main opacity-50 text-3xl"
|
||||
{!expandedEpisodes[`large-${ep.id}`] && (
|
||||
<div className="relative h-[158px] w-full bg-video-context-hoverColor">
|
||||
{ep.still_path ? (
|
||||
<img
|
||||
src={`https://image.tmdb.org/t/p/w300${ep.still_path}`}
|
||||
alt={ep.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Episode Number Badge */}
|
||||
<div className="absolute top-2 left-2 flex items-center space-x-2">
|
||||
<span className="p-0.5 px-2 rounded inline bg-video-context-hoverColor bg-opacity-80 text-video-context-type-main text-sm">
|
||||
E{ep.number}
|
||||
</span>
|
||||
{!isAired && (
|
||||
<span className="text-video-context-type-main/70 text-sm">
|
||||
{ep.air_date
|
||||
? `(${t("details.airs")} - ${new Date(ep.air_date).toLocaleDateString()})`
|
||||
: `(${t("media.unreleased")})`}
|
||||
</span>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-black bg-opacity-50">
|
||||
<Icon
|
||||
icon={Icons.FILM}
|
||||
className="text-video-context-type-main opacity-50 text-3xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Episode Number Badge */}
|
||||
<div className="absolute top-2 left-2 flex items-center space-x-2">
|
||||
<span className="p-0.5 px-2 rounded inline bg-video-context-hoverColor bg-opacity-80 text-video-context-type-main text-sm">
|
||||
E{ep.number}
|
||||
</span>
|
||||
{!isAired && (
|
||||
<span className="text-video-context-type-main/70 text-sm">
|
||||
{ep.air_date
|
||||
? `(${t("details.airs")} - ${new Date(ep.air_date).toLocaleDateString()})`
|
||||
: `(${t("media.unreleased")})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3">
|
||||
<h3 className="font-bold text-white line-clamp-1">
|
||||
{ep.title}
|
||||
</h3>
|
||||
<div
|
||||
className={classNames(
|
||||
"p-3",
|
||||
expandedEpisodes[`large-${ep.id}`]
|
||||
? "h-full"
|
||||
: "h-[122px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-bold text-white line-clamp-1">
|
||||
{ep.title}
|
||||
</h3>
|
||||
</div>
|
||||
{ep.overview && (
|
||||
<p className="text-sm text-white/80 mt-1.5 line-clamp-2">
|
||||
{ep.overview}
|
||||
</p>
|
||||
<div className="relative">
|
||||
<p
|
||||
ref={(el) => {
|
||||
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}
|
||||
</p>
|
||||
{!expandedEpisodes[`large-${ep.id}`] &&
|
||||
truncatedEpisodes[`large-${ep.id}`] && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) =>
|
||||
toggleEpisodeExpansion(`large-${ep.id}`, e)
|
||||
}
|
||||
className="text-sm text-white/60 hover:text-white transition-opacity duration-200 opacity-0 animate-fade-in"
|
||||
>
|
||||
{t("player.menus.episodes.showMore")}
|
||||
</button>
|
||||
)}
|
||||
{expandedEpisodes[`large-${ep.id}`] && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) =>
|
||||
toggleEpisodeExpansion(`large-${ep.id}`, e)
|
||||
}
|
||||
className="mt-2 text-sm text-white/60 hover:text-white transition-opacity duration-200 opacity-0 animate-fade-in"
|
||||
>
|
||||
{t("player.menus.episodes.showLess")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -456,7 +603,7 @@ function EpisodesOverlay({
|
|||
<OverlayPage id={id} path="/" width={343} height={431}>
|
||||
<SeasonsView setSeason={setSeason} selectedSeason={selectedSeason} />
|
||||
</OverlayPage>
|
||||
<OverlayPage id={id} path="/episodes" width={0} height={360} fullWidth>
|
||||
<OverlayPage id={id} path="/episodes" width={0} height={375} fullWidth>
|
||||
{selectedSeason.length > 0 ? (
|
||||
<EpisodesView
|
||||
selectedSeason={selectedSeason}
|
||||
|
|
|
|||
|
|
@ -29,8 +29,15 @@ const config: Config = {
|
|||
"0%, 40%, 100%": { height: "0.5em", "background-color": "#282336" },
|
||||
"20%": { height: "1em", "background-color": "white" },
|
||||
},
|
||||
"fade-in": {
|
||||
"0%": { opacity: "0" },
|
||||
"100%": { opacity: "1" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"loading-pin": "loading-pin 1.8s ease-in-out infinite",
|
||||
"fade-in": "fade-in 200ms ease-out forwards",
|
||||
},
|
||||
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" },
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
|
|
|||
Loading…
Reference in a new issue