first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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>
);
}

View 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;
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}