302
resources/client/videos/watch-page/episode-selector.tsx
Executable file
302
resources/client/videos/watch-page/episode-selector.tsx
Executable file
@@ -0,0 +1,302 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
13
resources/client/videos/watch-page/get-watch-link.ts
Executable file
13
resources/client/videos/watch-page/get-watch-link.ts
Executable file
@@ -0,0 +1,13 @@
|
||||
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
|
||||
import {Video} from '@app/titles/models/video';
|
||||
|
||||
export function getWatchLink(
|
||||
video: Video,
|
||||
{absolute}: {absolute?: boolean} = {}
|
||||
): string {
|
||||
let link = `/watch/${video.id}`;
|
||||
if (absolute) {
|
||||
link = `${getBootstrapData().settings.base_url}${link}`;
|
||||
}
|
||||
return link;
|
||||
}
|
||||
180
resources/client/videos/watch-page/watch-page-alternative-videos.tsx
Executable file
180
resources/client/videos/watch-page/watch-page-alternative-videos.tsx
Executable file
@@ -0,0 +1,180 @@
|
||||
import {UseWatchPageVideoResponse} from '@app/videos/requests/use-watch-page-video';
|
||||
import {MediaPlayIcon} from '@common/icons/media/media-play';
|
||||
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Link, useParams} from 'react-router-dom';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import clsx from 'clsx';
|
||||
import {Video} from '@app/titles/models/video';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import React, {Fragment} from 'react';
|
||||
import {EpisodeSelector} from '@app/videos/watch-page/episode-selector';
|
||||
import {MediaEpisodesIcon} from '@common/icons/media/media-episodes';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
} from '@common/ui/navigation/menu/menu-trigger';
|
||||
|
||||
const className = 'flex items-center flex-wrap gap-14';
|
||||
|
||||
interface Props {
|
||||
data: UseWatchPageVideoResponse | undefined;
|
||||
}
|
||||
export function WatchPageAlternativeVideos({data}: Props) {
|
||||
const navigate = useNavigate();
|
||||
const {streaming} = useSettings();
|
||||
const title = data?.title;
|
||||
const episode = data?.episode;
|
||||
const video = data?.video;
|
||||
|
||||
const showEpisodeSelector =
|
||||
title &&
|
||||
episode &&
|
||||
video &&
|
||||
(video.type === 'embed' || video.type === 'external');
|
||||
|
||||
if (!showEpisodeSelector && !streaming.show_video_selector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-14 flex items-start justify-between gap-48">
|
||||
{streaming.show_video_selector && (
|
||||
<Fragment>
|
||||
<VideoDropdown
|
||||
className="lg:hidden"
|
||||
videos={data?.alternative_videos || []}
|
||||
/>
|
||||
<div className="max-lg:hidden">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{data ? (
|
||||
<VideoList videos={data.alternative_videos} />
|
||||
) : (
|
||||
<Skeletons />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
{showEpisodeSelector && (
|
||||
<EpisodeSelector
|
||||
title={title}
|
||||
currentEpisode={episode}
|
||||
onSelected={episode => {
|
||||
navigate(getWatchLink(episode.primary_video));
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-40"
|
||||
startIcon={<MediaEpisodesIcon />}
|
||||
>
|
||||
<Trans message="Episodes" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoDropdownProps {
|
||||
videos: Video[];
|
||||
className?: string;
|
||||
}
|
||||
function VideoDropdown({videos, className}: VideoDropdownProps) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<MenuTrigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={clsx('min-h-40', className)}
|
||||
startIcon={<MediaPlayIcon />}
|
||||
>
|
||||
<Trans message="Other sources" />
|
||||
</Button>
|
||||
<Menu>
|
||||
{videos.map(video => (
|
||||
<MenuItem
|
||||
value={video.id}
|
||||
key={video.id}
|
||||
startIcon={<MediaPlayIcon />}
|
||||
endSection={<QualityBadge video={video} />}
|
||||
onSelected={() => navigate(getWatchLink(video))}
|
||||
>
|
||||
{video.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoListProps {
|
||||
videos: Video[];
|
||||
}
|
||||
function VideoList({videos}: VideoListProps) {
|
||||
const {videoId} = useParams();
|
||||
|
||||
if (videos.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<m.div
|
||||
key="alternative-sources"
|
||||
className={className}
|
||||
{...opacityAnimation}
|
||||
>
|
||||
{videos.map(video => (
|
||||
<Button
|
||||
elementType={Link}
|
||||
to={getWatchLink(video)}
|
||||
key={video.id}
|
||||
variant="outline"
|
||||
color={videoId === `${video.id}` ? 'primary' : 'chip'}
|
||||
startIcon={<MediaPlayIcon aria-hidden />}
|
||||
className="min-h-40 gap-10"
|
||||
>
|
||||
{video.name}
|
||||
<QualityBadge video={video} />
|
||||
</Button>
|
||||
))}
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface QualityBadgeProps {
|
||||
video: Video;
|
||||
}
|
||||
function QualityBadge({video}: QualityBadgeProps) {
|
||||
if (!video.quality || video.quality === 'default') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span className="rounded border px-6 text-xs font-bold uppercase">
|
||||
{video.quality}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Skeletons() {
|
||||
return (
|
||||
<m.div
|
||||
key="skeletons"
|
||||
className={clsx(className, 'h-40')}
|
||||
{...opacityAnimation}
|
||||
>
|
||||
<Skeleton variant="rect" size="h-full w-[116px]" />
|
||||
<Skeleton variant="rect" size="h-full w-[116px]" />
|
||||
<Skeleton variant="rect" size="h-full w-[116px]" />
|
||||
<Skeleton variant="rect" size="h-full w-[116px]" />
|
||||
<Skeleton variant="rect" size="h-full w-[116px]" />
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
99
resources/client/videos/watch-page/watch-page-aside.tsx
Executable file
99
resources/client/videos/watch-page/watch-page-aside.tsx
Executable file
@@ -0,0 +1,99 @@
|
||||
import {SiteSectionHeading} from '@app/titles/site-section-heading';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {VideoGridItem, VideoGridItemSkeleton} from '@app/titles/video-grid';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {useWatchPageVideo} from '@app/videos/requests/use-watch-page-video';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import {useIsStreamingMode} from '@app/videos/use-is-streaming-mode';
|
||||
import {Video} from '@app/titles/models/video';
|
||||
import {CompactSeasonEpisode} from '@app/episodes/compact-season-episode';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export function WatchPageAside() {
|
||||
const {data} = useWatchPageVideo();
|
||||
|
||||
const content = !data ? (
|
||||
<m.div key="skeleton" {...opacityAnimation}>
|
||||
<VideoGridItemSkeleton className="mb-34" />
|
||||
<VideoGridItemSkeleton className="mb-34" />
|
||||
<VideoGridItemSkeleton className="mb-34" />
|
||||
</m.div>
|
||||
) : (
|
||||
<m.div key="loaded" {...opacityAnimation}>
|
||||
{data.related_videos.map(video => (
|
||||
<RelatedVideo video={video} key={video.id} activeVideo={data.video} />
|
||||
))}
|
||||
</m.div>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="w-350 flex-shrink-0 max-lg:mt-54">
|
||||
<SiteSectionHeading
|
||||
fontWeight="font-medium"
|
||||
fontSize="text-2xl"
|
||||
margin="mb-28"
|
||||
>
|
||||
<Header video={data?.video} />
|
||||
</SiteSectionHeading>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{content}
|
||||
</AnimatePresence>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
video?: Video;
|
||||
}
|
||||
function Header({video}: HeaderProps) {
|
||||
const isStreamingMode = useIsStreamingMode();
|
||||
|
||||
if (!video) {
|
||||
return <div className="h-32" />;
|
||||
}
|
||||
|
||||
return isStreamingMode ? (
|
||||
<Trans message="Related movies & series" />
|
||||
) : (
|
||||
<Trans message="Related videos" />
|
||||
);
|
||||
}
|
||||
|
||||
interface RelatedVideoProps {
|
||||
video: Video;
|
||||
activeVideo: Video;
|
||||
}
|
||||
function RelatedVideo({video, activeVideo}: RelatedVideoProps) {
|
||||
const isStreamingMode = useIsStreamingMode();
|
||||
|
||||
let name: ReactNode = video.name;
|
||||
|
||||
if (isStreamingMode) {
|
||||
if (video.episode) {
|
||||
name = (
|
||||
<span>
|
||||
{video.episode.name} (<CompactSeasonEpisode episode={video.episode} />
|
||||
)
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
name = video.title!.name;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<VideoGridItem
|
||||
video={video}
|
||||
title={video.title}
|
||||
episode={video.episode}
|
||||
forceTitleBackdrop={isStreamingMode}
|
||||
className={clsx(
|
||||
'mb-24 text-sm',
|
||||
activeVideo.id === video.id && 'text-primary'
|
||||
)}
|
||||
showCategory={!isStreamingMode}
|
||||
name={name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
163
resources/client/videos/watch-page/watch-page-title-details.tsx
Executable file
163
resources/client/videos/watch-page/watch-page-title-details.tsx
Executable file
@@ -0,0 +1,163 @@
|
||||
import {useWatchPageVideo} from '@app/videos/requests/use-watch-page-video';
|
||||
import {TitlePoster} from '@app/titles/title-poster/title-poster';
|
||||
import {TitleLink} from '@app/titles/title-link';
|
||||
import React, {Fragment, ReactNode, useState} from 'react';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import {ThumbButtons} from '@common/votes/thumb-buttons';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ShareIcon} from '@common/icons/material/Share';
|
||||
import {FlagIcon} from '@common/icons/material/Flag';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {useSubmitReport} from '@common/reports/requests/use-submit-report';
|
||||
import {useDeleteReport} from '@common/reports/requests/use-delete-report';
|
||||
import {Video} from '@app/titles/models/video';
|
||||
import {useIsStreamingMode} from '@app/videos/use-is-streaming-mode';
|
||||
import {EpisodeLink} from '@app/episodes/episode-link';
|
||||
import {CompactSeasonEpisode} from '@app/episodes/compact-season-episode';
|
||||
import {ShareMenuTrigger} from '@app/sharing/share-menu-trigger';
|
||||
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
|
||||
|
||||
export function WatchPageTitleDetails() {
|
||||
const {data} = useWatchPageVideo();
|
||||
const isStreamingMode = useIsStreamingMode();
|
||||
|
||||
const content = !data ? (
|
||||
<Layout
|
||||
key="skeleton"
|
||||
poster={<Skeleton variant="rect" size="w-132 aspect-poster" />}
|
||||
titleLink={<Skeleton className="max-w-144" />}
|
||||
videoName={<Skeleton className="max-w-240" />}
|
||||
description={
|
||||
<Fragment>
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</Fragment>
|
||||
}
|
||||
rate={
|
||||
<div className="flex h-32 items-center gap-2">
|
||||
<Skeleton variant="rect" size="w-56 h-24" className="mr-10" />
|
||||
<Skeleton variant="rect" size="w-56 h-24" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Layout
|
||||
key="loaded"
|
||||
poster={
|
||||
<TitlePoster
|
||||
size="w-132"
|
||||
srcSize="md"
|
||||
title={data.title}
|
||||
showPlayButton
|
||||
className="max-md:hidden"
|
||||
/>
|
||||
}
|
||||
titleLink={<TitleLink title={data.title} />}
|
||||
videoName={!isStreamingMode ? data.video.name : undefined}
|
||||
episodeName={
|
||||
data.episode ? (
|
||||
<EpisodeLink title={data.title} episode={data.episode}>
|
||||
{data.episode.name} (<CompactSeasonEpisode episode={data.episode} />
|
||||
)
|
||||
</EpisodeLink>
|
||||
) : undefined
|
||||
}
|
||||
description={data.episode?.description || data.title.description}
|
||||
rate={
|
||||
<div className="flex items-center gap-2">
|
||||
<ThumbButtons model={data.video} className="mr-auto" />
|
||||
<ReportButton video={data.video} />
|
||||
<ShareButton video={data.video} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{content}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShareButtonProps {
|
||||
video: Video;
|
||||
}
|
||||
function ShareButton({video}: ShareButtonProps) {
|
||||
const link = getWatchLink(video, {absolute: true});
|
||||
return (
|
||||
<ShareMenuTrigger link={link}>
|
||||
<Tooltip label={<Trans message="Share" />}>
|
||||
<IconButton>
|
||||
<ShareIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ShareMenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReportButtonProps {
|
||||
video: Video;
|
||||
}
|
||||
function ReportButton({video}: ReportButtonProps) {
|
||||
const report = useSubmitReport(video);
|
||||
const deleteReport = useDeleteReport(video);
|
||||
const [isReported, setIsReported] = useState(video.current_user_reported);
|
||||
|
||||
return (
|
||||
<Tooltip label={<Trans message="Report" />}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (isReported) {
|
||||
deleteReport.mutate();
|
||||
} else {
|
||||
report.mutate({});
|
||||
}
|
||||
setIsReported(!isReported);
|
||||
}}
|
||||
>
|
||||
<FlagIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface LayoutProps {
|
||||
poster?: ReactNode;
|
||||
titleLink: ReactNode;
|
||||
videoName: ReactNode;
|
||||
episodeName?: ReactNode;
|
||||
description: ReactNode;
|
||||
rate?: ReactNode;
|
||||
}
|
||||
function Layout({
|
||||
poster,
|
||||
titleLink,
|
||||
videoName,
|
||||
episodeName,
|
||||
description,
|
||||
rate,
|
||||
}: LayoutProps) {
|
||||
return (
|
||||
<m.div
|
||||
className="flex items-start gap-16 overflow-hidden rounded pr-6"
|
||||
{...opacityAnimation}
|
||||
>
|
||||
{poster}
|
||||
<div className="flex-auto py-6">
|
||||
<h1 className="mb-6 text-2xl font-medium">{titleLink}</h1>
|
||||
{episodeName && (
|
||||
<div className="text-base font-medium">{episodeName}</div>
|
||||
)}
|
||||
{videoName && <div className="text-base font-medium">{videoName}</div>}
|
||||
<div className="my-12">{rate}</div>
|
||||
{description && (
|
||||
<p className="max-w-780 text-sm text-muted">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
167
resources/client/videos/watch-page/watch-page.tsx
Executable file
167
resources/client/videos/watch-page/watch-page.tsx
Executable file
@@ -0,0 +1,167 @@
|
||||
import React, {Fragment, useState} from 'react';
|
||||
import {MainNavbar} from '@app/main-navbar';
|
||||
import {useDarkThemeVariables} from '@common/ui/themes/use-dark-theme-variables';
|
||||
import {
|
||||
useWatchPageVideo,
|
||||
UseWatchPageVideoResponse,
|
||||
} from '@app/videos/requests/use-watch-page-video';
|
||||
import {Footer} from '@common/ui/footer/footer';
|
||||
import {PageErrorMessage} from '@common/errors/page-error-message';
|
||||
import {CommentList} from '@common/comments/comment-list/comment-list';
|
||||
import {NewCommentForm} from '@common/comments/new-comment-form';
|
||||
import {WatchPageTitleDetails} from '@app/videos/watch-page/watch-page-title-details';
|
||||
import {WatchPageAside} from '@app/videos/watch-page/watch-page-aside';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import {useScrollToTop} from '@common/ui/navigation/use-scroll-to-top';
|
||||
import {VideoPlayerSkeleton} from '@app/videos/video-player-skeleton';
|
||||
import {SiteVideoPlayer} from '@app/videos/site-video-player';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import {useIsStreamingMode} from '@app/videos/use-is-streaming-mode';
|
||||
import {WatchPageAlternativeVideos} from '@app/videos/watch-page/watch-page-alternative-videos';
|
||||
import {AdHost} from '@common/admin/ads/ad-host';
|
||||
import {Episode} from '@app/titles/models/episode';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {Video} from '@app/titles/models/video';
|
||||
import {PageMetaTags} from '@common/http/page-meta-tags';
|
||||
import {useLayoutEffect} from '@react-aria/utils';
|
||||
import {VideoThumbnail} from '@app/videos/video-thumbnail';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Link} from 'react-router-dom';
|
||||
|
||||
export function WatchPage() {
|
||||
const darkThemeVars = useDarkThemeVariables();
|
||||
useScrollToTop();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<MainNavbar />
|
||||
<div style={darkThemeVars} className="dark min-h-screen bg text">
|
||||
<div className="container mx-auto p-14 md:p-24">
|
||||
<Content />
|
||||
<Footer className="mt-48" />
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function Content() {
|
||||
const {titles, comments} = useSettings();
|
||||
const {isLoggedIn, hasPermission} = useAuth();
|
||||
const query = useWatchPageVideo();
|
||||
const {data, isLoading} = query;
|
||||
const title = data?.title;
|
||||
const episode = data?.episode;
|
||||
const video = data?.video;
|
||||
let commentable: Episode | Title | Video | undefined = video;
|
||||
|
||||
if (!comments?.per_video) {
|
||||
commentable = episode || title;
|
||||
}
|
||||
|
||||
const shouldShowComments =
|
||||
title && video && titles.enable_comments && hasPermission('comments.view');
|
||||
|
||||
if (data || isLoading) {
|
||||
return (
|
||||
<Fragment key={video?.id || 'loading'}>
|
||||
<PageMetaTags query={query} />
|
||||
<VideoWrapper data={data} />
|
||||
<WatchPageAlternativeVideos data={data} />
|
||||
<AdHost slot="watch_top" className="pt-48" />
|
||||
<section className="mt-42 items-start gap-56 lg:flex">
|
||||
<div className="flex-auto">
|
||||
<WatchPageTitleDetails />
|
||||
{shouldShowComments && (
|
||||
<CommentList
|
||||
commentable={commentable!}
|
||||
className="mt-44"
|
||||
perPage={20}
|
||||
>
|
||||
{isLoggedIn && hasPermission('comments.create') && (
|
||||
<NewCommentForm
|
||||
commentable={commentable!}
|
||||
className="mb-14 mt-24"
|
||||
/>
|
||||
)}
|
||||
</CommentList>
|
||||
)}
|
||||
</div>
|
||||
<WatchPageAside />
|
||||
</section>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return <PageErrorMessage />;
|
||||
}
|
||||
|
||||
interface VideoWrapperProps {
|
||||
data?: UseWatchPageVideoResponse;
|
||||
}
|
||||
function VideoWrapper({data}: VideoWrapperProps) {
|
||||
const isStreamingMode = useIsStreamingMode();
|
||||
const {hasPermission} = useAuth();
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
setIsVisible(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{data?.video && isVisible ? (
|
||||
<m.div key="player" {...opacityAnimation}>
|
||||
{hasPermission('videos.play') ? (
|
||||
<SiteVideoPlayer
|
||||
title={data.title}
|
||||
episode={data.episode}
|
||||
video={data.video}
|
||||
relatedVideos={data.related_videos}
|
||||
autoPlay
|
||||
logPlays
|
||||
showEpisodeSelector={isStreamingMode}
|
||||
/>
|
||||
) : (
|
||||
<UpgradeMessage video={data.video} />
|
||||
)}
|
||||
</m.div>
|
||||
) : (
|
||||
<m.div className="relative" key="skeleton" {...opacityAnimation}>
|
||||
<VideoPlayerSkeleton animate />
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
interface UpgradeMessageProps {
|
||||
video: Video;
|
||||
}
|
||||
function UpgradeMessage({video}: UpgradeMessageProps) {
|
||||
return (
|
||||
<div className="relative flex aspect-video items-center justify-center bg-alt">
|
||||
<div className="blur">
|
||||
<VideoThumbnail video={video} />
|
||||
</div>
|
||||
<div className="absolute h-max w-max max-w-full rounded-lg bg-black/60 p-24 text-lg font-medium">
|
||||
<div>
|
||||
<Trans message="Your current plan does not allow watching videos. Upgrade to unlock this feature." />
|
||||
</div>
|
||||
<div className="mt-14 text-center">
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
elementType={Link}
|
||||
to="/pricing"
|
||||
>
|
||||
<Trans message="Upgrade" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user