Files
mtdb_movie/resources/client/videos/watch-page/episode-selector.tsx
maher 703f50a09d
Some checks failed
Build / run (push) Has been cancelled
first commit
2025-10-29 11:42:25 +01:00

303 lines
8.4 KiB
TypeScript
Executable File

import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {IconButton} from '@common/ui/buttons/icon-button';
import {Dialog} from '@common/ui/overlays/dialog/dialog';
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
import {Trans} from '@common/i18n/trans';
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
import {useDarkThemeVariables} from '@common/ui/themes/use-dark-theme-variables';
import {Title} from '@app/titles/models/title';
import {Episode} from '@app/titles/models/episode';
import {Accordion, AccordionItem} from '@common/ui/accordion/accordion';
import {EpisodePoster} from '@app/episodes/episode-poster/episode-poster';
import {MediaPlayIcon} from '@common/icons/media/media-play';
import React, {Fragment, ReactElement, ReactNode, useState} from 'react';
import {ArrowBackIcon} from '@common/icons/material/ArrowBack';
import {List, ListItem} from '@common/ui/list/list';
import {FullPageLoader} from '@common/ui/progress/full-page-loader';
import {ArrowForwardIcon} from '@common/icons/material/ArrowForward';
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
import {TvIcon} from '@common/icons/material/Tv';
import {AnimatePresence, m} from 'framer-motion';
import {useSeasonEpisodes} from '@app/titles/requests/use-season-episodes';
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
import {Tooltip} from '@common/ui/tooltip/tooltip';
import {MediaEpisodesIcon} from '@common/icons/media/media-episodes';
interface Props {
title: Title;
currentEpisode: Episode;
onSelected: (episode: Episode) => void;
trigger?: ReactElement;
}
export function EpisodeSelector(props: Props) {
const trigger = props.trigger || (
<Tooltip label={<Trans message="Episodes" />}>
<IconButton>
<MediaEpisodesIcon />
</IconButton>
</Tooltip>
);
return (
<DialogTrigger type="popover" placement="top">
{trigger}
<EpisodeSelectorDialog {...props} />
</DialogTrigger>
);
}
type SelectorPanel = 'episodes' | 'seasons';
function EpisodeSelectorDialog({title, currentEpisode, onSelected}: Props) {
const {close} = useDialogContext();
const darkThemeVars = useDarkThemeVariables();
const [activeTab, setActiveTab] = useState<SelectorPanel>('episodes');
const [selectedSeason, setSelectedSeason] = useState(
currentEpisode.season_number
);
const heading =
activeTab === 'episodes' ? (
<Trans message="Season :number" values={{number: selectedSeason}} />
) : (
title.name
);
const showBackButton = activeTab === 'episodes' && title.seasons_count > 1;
return (
<Dialog style={darkThemeVars} className="dark" size="lg">
<DialogHeader
titleTextSize="text-md"
closeButtonSize="md"
className="h-60"
padding={showBackButton ? 'pl-10 pr-20' : 'px-20'}
leftAdornment={
showBackButton ? (
<IconButton onClick={() => setActiveTab('seasons')}>
<ArrowBackIcon />
</IconButton>
) : null
}
>
{heading}
</DialogHeader>
<DialogBody
className="stable-scrollbar relative h-400 text-main"
padding="p-0"
>
<AnimatePresence initial={false}>
{activeTab === 'episodes' ? (
<EpisodeList
title={title}
season={selectedSeason}
onSelected={episode => {
close();
onSelected(episode);
}}
selectedEpisodeId={
currentEpisode.season_number === selectedSeason
? currentEpisode.id
: undefined
}
/>
) : (
<SeasonList
title={title}
selectedSeason={selectedSeason}
onSelected={number => {
setSelectedSeason(number);
setActiveTab('episodes');
}}
/>
)}
</AnimatePresence>
</DialogBody>
</Dialog>
);
}
interface SeasonListProps {
title: Title;
onSelected: (number: number) => void;
selectedSeason?: number;
}
function SeasonList({title, onSelected, selectedSeason}: SeasonListProps) {
return (
<AnimatedPanel name="seasons">
<List>
{[...new Array(title.seasons_count).keys()].map(season => {
const seasonNumber = season + 1;
return (
<ListItem
className="group"
endIcon={
<ArrowForwardIcon
className="invisible group-hover:visible"
size="sm"
/>
}
showCheckmark
isSelected={selectedSeason === seasonNumber}
onSelected={() => onSelected(seasonNumber)}
key={seasonNumber}
onClick={() => onSelected(seasonNumber)}
>
<Trans message="Season :number" values={{number: seasonNumber}} />
</ListItem>
);
})}
</List>
</AnimatedPanel>
);
}
interface EpisodeListProps {
title: Title;
season: number;
onSelected: (episode: Episode) => void;
selectedEpisodeId: number | undefined;
}
function EpisodeList({
title,
season,
selectedEpisodeId,
onSelected,
}: EpisodeListProps) {
const query = useSeasonEpisodes(
undefined,
{truncateDescriptions: 'true'},
{titleId: title.id, season}
);
let content: ReactNode;
if (query.noResults) {
content = (
<IllustratedMessage
className="pt-56"
imageMargin="mb-8"
image={
<div className="text-muted">
<TvIcon size="xl" />
</div>
}
imageHeight="h-auto"
title={<Trans message="This season has not episodes yet." />}
/>
);
} else if (query.isInitialLoading) {
content = <FullPageLoader />;
} else {
content = (
<Fragment>
<Accordion
defaultExpandedValues={
selectedEpisodeId ? [selectedEpisodeId] : undefined
}
>
{query.items.map(episode => (
<AccordionItem
value={episode.id}
key={episode.id}
buttonPadding="py-10 pl-26 pr-10"
label={
<div className="flex items-center gap-14">
<div>{episode.episode_number}</div>
<div>{episode.name}</div>
</div>
}
>
<EpisodeItem
title={title}
episode={episode}
isSelected={episode.id === selectedEpisodeId}
onSelected={() => onSelected(episode)}
/>
</AccordionItem>
))}
</Accordion>
<InfiniteScrollSentinel query={query} />
</Fragment>
);
}
return <AnimatedPanel name="episodes">{content}</AnimatedPanel>;
}
interface EpisodeItemProps {
title: Title;
episode: Episode;
isSelected: boolean;
onSelected: () => void;
}
function EpisodeItem({
episode,
title,
isSelected,
onSelected,
}: EpisodeItemProps) {
const isPlayable = !isSelected && episode.primary_video;
return (
<div
className="flex gap-20 text-lg text-main"
onClick={isPlayable ? () => onSelected() : undefined}
>
<EpisodePoster
wrapWithLink={false}
size="w-224"
title={title}
episode={episode}
>
{isPlayable ? (
<IconButton variant="flat" color="white">
<MediaPlayIcon />
</IconButton>
) : undefined}
</EpisodePoster>
<p className="pt-12 text-sm">{episode.description}</p>
</div>
);
}
const variants = {
enter: (activeTab: SelectorPanel) => {
return {
x: activeTab === 'episodes' ? 608 : -608,
opacity: 0,
};
},
center: {
x: 0,
opacity: 1,
},
exit: (direction: SelectorPanel) => {
return {
zIndex: 0,
x: direction === 'seasons' ? 608 : -608,
opacity: 0,
};
},
};
interface AnimatedPanelProps {
name: SelectorPanel;
children: ReactNode;
}
function AnimatedPanel({name, children}: AnimatedPanelProps) {
return (
<m.div
className="absolute h-full w-full"
key={name}
custom={name}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{type: 'tween', duration: 0.15}}
>
{children}
</m.div>
);
}