303 lines
8.4 KiB
TypeScript
Executable File
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>
|
|
);
|
|
}
|