33
resources/client/videos/requests/use-log-video-play.ts
Executable file
33
resources/client/videos/requests/use-log-video-play.ts
Executable file
@@ -0,0 +1,33 @@
|
||||
import {PlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {MediaItem} from '@common/player/media-item';
|
||||
import {Video} from '@app/titles/models/video';
|
||||
import {MutableRefObject, useCallback} from 'react';
|
||||
import {getCookie} from 'react-use-cookie';
|
||||
|
||||
interface Options {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useLogVideoPlay(
|
||||
playerRef: MutableRefObject<PlayerActions>,
|
||||
{enabled = true}: Options = {}
|
||||
) {
|
||||
return useCallback((): boolean => {
|
||||
const player = playerRef.current;
|
||||
if (!player || !enabled) {
|
||||
return false;
|
||||
}
|
||||
const media = player.getState().cuedMedia as MediaItem<Video> | undefined;
|
||||
if (!media?.meta?.id || player.getCurrentTime() === 0) {
|
||||
return false;
|
||||
}
|
||||
return navigator.sendBeacon(
|
||||
`/api/v1/videos/${media.meta.id}/log-play`,
|
||||
JSON.stringify({
|
||||
currentTime: player.getCurrentTime(),
|
||||
duration: player.getState().mediaDuration,
|
||||
_token: getCookie('XSRF-TOKEN'),
|
||||
})
|
||||
);
|
||||
}, [playerRef, enabled]);
|
||||
}
|
||||
37
resources/client/videos/requests/use-watch-page-video.ts
Executable file
37
resources/client/videos/requests/use-watch-page-video.ts
Executable file
@@ -0,0 +1,37 @@
|
||||
import {keepPreviousData, useQuery} from '@tanstack/react-query';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {Video} from '@app/titles/models/video';
|
||||
import {Episode} from '@app/titles/models/episode';
|
||||
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
|
||||
|
||||
export interface UseWatchPageVideoResponse extends BackendResponse {
|
||||
title: Title;
|
||||
episode?: Episode;
|
||||
video: Video;
|
||||
related_videos: Video[];
|
||||
alternative_videos: Video[];
|
||||
}
|
||||
|
||||
export function useWatchPageVideo() {
|
||||
const {videoId} = useParams();
|
||||
return useQuery<UseWatchPageVideoResponse>({
|
||||
queryKey: ['video', 'watch-page', videoId],
|
||||
queryFn: () => fetchVideo(videoId),
|
||||
placeholderData: keepPreviousData,
|
||||
initialData: () => {
|
||||
const data = getBootstrapData().loaders?.watchPage;
|
||||
if (data && `${data.video.id}` === videoId) {
|
||||
return data;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function fetchVideo(videoId?: string) {
|
||||
return apiClient
|
||||
.get<UseWatchPageVideoResponse>(`watch/${videoId}`)
|
||||
.then(response => response.data);
|
||||
}
|
||||
181
resources/client/videos/site-video-player.tsx
Executable file
181
resources/client/videos/site-video-player.tsx
Executable file
@@ -0,0 +1,181 @@
|
||||
import {guessPlayerProvider} from '@common/player/utils/guess-player-provider';
|
||||
import {VideoPlayer} from '@common/player/ui/video-player/video-player';
|
||||
import {VideoThumbnail} from '@app/videos/video-thumbnail';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {MediaPlayIcon} from '@common/icons/media/media-play';
|
||||
import React, {memo, useEffect, useRef} from 'react';
|
||||
import {Video} from '@app/titles/models/video';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {Episode} from '@app/titles/models/episode';
|
||||
import {VideoPlayerSkeleton} from '@app/videos/video-player-skeleton';
|
||||
import {MediaItem} from '@common/player/media-item';
|
||||
import {useLogVideoPlay} from '@app/videos/requests/use-log-video-play';
|
||||
import {PlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {isSameMedia} from '@common/player/utils/is-same-media';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {EpisodeSelector} from '@app/videos/watch-page/episode-selector';
|
||||
|
||||
interface Props {
|
||||
video: Video;
|
||||
relatedVideos?: Video[];
|
||||
autoPlay?: boolean;
|
||||
title?: Title;
|
||||
episode?: Episode;
|
||||
mediaItemId?: string;
|
||||
logPlays?: boolean;
|
||||
showEpisodeSelector?: boolean;
|
||||
}
|
||||
export const SiteVideoPlayer = memo((props: Props) => {
|
||||
const {video, autoPlay, title, episode} = props;
|
||||
if (
|
||||
video.type === 'video' ||
|
||||
video.type === 'stream' ||
|
||||
(video.type === 'embed' && video.src.includes('youtube'))
|
||||
) {
|
||||
return <NativeVideoPlayer {...props} />;
|
||||
}
|
||||
|
||||
if (video.type === 'embed') {
|
||||
return <EmbedPlayer src={video.src} autoPlay={autoPlay} />;
|
||||
}
|
||||
|
||||
if (video.type === 'external') {
|
||||
return (
|
||||
<div className="relative">
|
||||
<VideoThumbnail
|
||||
title={title}
|
||||
episode={episode}
|
||||
video={video}
|
||||
fallback={<div className="aspect-video w-full bg-fg-base/4" />}
|
||||
/>
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center"
|
||||
onClick={() => window.open(video.src, '_blank')}
|
||||
>
|
||||
<IconButton variant="flat" color="primary" size="lg">
|
||||
<MediaPlayIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <VideoPlayerSkeleton />;
|
||||
});
|
||||
|
||||
interface EmberPlayerProps {
|
||||
src: string;
|
||||
autoPlay?: boolean;
|
||||
}
|
||||
const EmbedPlayer = memo(({src, autoPlay}: EmberPlayerProps) => {
|
||||
let finalSrc = '';
|
||||
try {
|
||||
const url = src.includes('<iframe') ? src.match(/src="([^"]*)"/)?.[1] : src;
|
||||
const parsed = new URL(url || '');
|
||||
parsed.searchParams.set('autoplay', autoPlay ? '1' : '0');
|
||||
finalSrc = parsed.toString();
|
||||
} catch {}
|
||||
|
||||
if (!finalSrc) {
|
||||
return (
|
||||
<div className="flex aspect-video w-full items-center justify-center">
|
||||
<div className="rounded-panel border p-10">
|
||||
<Trans message="There was an issue playting this video." />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={finalSrc}
|
||||
className="aspect-video w-full"
|
||||
allowFullScreen
|
||||
allow="autoplay; encrypted-media; picture-in-picture;"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function NativeVideoPlayer({
|
||||
video,
|
||||
title,
|
||||
episode,
|
||||
mediaItemId,
|
||||
relatedVideos,
|
||||
autoPlay,
|
||||
logPlays,
|
||||
showEpisodeSelector,
|
||||
}: Props) {
|
||||
const playerRef = useRef<PlayerActions>(null!);
|
||||
const logVideoPlay = useLogVideoPlay(playerRef, {enabled: logPlays});
|
||||
const mediaItem = videoToMediaItem(video, mediaItemId);
|
||||
const related = relatedVideos?.map(v => videoToMediaItem(v)) ?? [];
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
logVideoPlay();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () =>
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}, [logVideoPlay]);
|
||||
|
||||
return (
|
||||
<VideoPlayer
|
||||
apiRef={playerRef}
|
||||
id="player"
|
||||
queue={[mediaItem, ...related]}
|
||||
autoPlay={autoPlay}
|
||||
onBeforePlayNext={nextMedia => {
|
||||
if (nextMedia && !isSameMedia(mediaItem, nextMedia)) {
|
||||
navigate(getWatchLink(nextMedia.meta));
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
onDestroy={() => logVideoPlay()}
|
||||
listeners={{
|
||||
playbackEnd: () => logVideoPlay(),
|
||||
beforeCued: ({previous}) => {
|
||||
// only log when cueing from previous video and not when cueing initial one
|
||||
if (previous) {
|
||||
logVideoPlay();
|
||||
}
|
||||
},
|
||||
}}
|
||||
rightActions={
|
||||
showEpisodeSelector && title && episode ? (
|
||||
<EpisodeSelector
|
||||
title={title}
|
||||
currentEpisode={episode}
|
||||
onSelected={episode => {
|
||||
navigate(getWatchLink(episode.primary_video));
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function videoToMediaItem(video: Video, mediaItemId?: string): MediaItem {
|
||||
return {
|
||||
id: mediaItemId || video.id,
|
||||
provider: guessPlayerProvider(video.src),
|
||||
src: video.src,
|
||||
poster: video.thumbnail,
|
||||
meta: video,
|
||||
initialTime: video.latest_play?.time_watched ?? undefined,
|
||||
captions: video.captions?.map(caption => ({
|
||||
id: caption.id,
|
||||
src: caption.url,
|
||||
label: caption.name,
|
||||
language: caption.language,
|
||||
})),
|
||||
};
|
||||
}
|
||||
6
resources/client/videos/use-is-streaming-mode.ts
Executable file
6
resources/client/videos/use-is-streaming-mode.ts
Executable file
@@ -0,0 +1,6 @@
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
|
||||
export function useIsStreamingMode() {
|
||||
const {streaming} = useSettings();
|
||||
return streaming?.prefer_full || false;
|
||||
}
|
||||
22
resources/client/videos/video-player-skeleton.tsx
Executable file
22
resources/client/videos/video-player-skeleton.tsx
Executable file
@@ -0,0 +1,22 @@
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import {MediaPlayIcon} from '@common/icons/media/media-play';
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
animate?: boolean;
|
||||
}
|
||||
export function VideoPlayerSkeleton({animate}: Props) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Skeleton
|
||||
variant="rect"
|
||||
className="aspect-video"
|
||||
animation={animate ? 'pulsate' : null}
|
||||
/>
|
||||
<MediaPlayIcon
|
||||
className="absolute inset-0 m-auto text-fg-base/40"
|
||||
size="w-80 h-80"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
resources/client/videos/video-thumbnail.tsx
Executable file
101
resources/client/videos/video-thumbnail.tsx
Executable file
@@ -0,0 +1,101 @@
|
||||
import {Video} from '@app/titles/models/video';
|
||||
import React, {ReactElement, useEffect, useState} from 'react';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {Episode} from '@app/titles/models/episode';
|
||||
import {TitleBackdrop} from '@app/titles/title-poster/title-backdrop';
|
||||
import {ImageSize} from '@app/images/use-image-src';
|
||||
import {VideoPlayerSkeleton} from '@app/videos/video-player-skeleton';
|
||||
import clsx from 'clsx';
|
||||
import {loadYoutubePoster} from '@common/player/providers/youtube/load-youtube-poster';
|
||||
import {youtubeIdFromSrc} from '@common/player/utils/youtube-id-from-src';
|
||||
|
||||
interface Props {
|
||||
video: Video;
|
||||
isLazy?: boolean;
|
||||
title?: Title;
|
||||
episode?: Episode;
|
||||
srcSize?: ImageSize;
|
||||
size?: string;
|
||||
fallback?: ReactElement;
|
||||
forceTitleBackdrop?: boolean;
|
||||
}
|
||||
export function VideoThumbnail({
|
||||
video,
|
||||
isLazy,
|
||||
title,
|
||||
episode,
|
||||
srcSize,
|
||||
size = 'w-full max-h-full',
|
||||
fallback,
|
||||
forceTitleBackdrop = false,
|
||||
}: Props) {
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | undefined>(
|
||||
video.thumbnail
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!video.thumbnail &&
|
||||
!forceTitleBackdrop &&
|
||||
video.src.includes('youtube')
|
||||
) {
|
||||
const youtubeId = youtubeIdFromSrc(video.src);
|
||||
if (youtubeId) {
|
||||
loadYoutubePoster(youtubeId).then(url => {
|
||||
if (url) {
|
||||
setThumbnailUrl(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [video.src, video.thumbnail, forceTitleBackdrop]);
|
||||
|
||||
if (forceTitleBackdrop || !thumbnailUrl) {
|
||||
if (title) {
|
||||
return (
|
||||
<TitleBackdropFallback
|
||||
title={title}
|
||||
episode={episode}
|
||||
srcSize={srcSize}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
return <VideoPlayerSkeleton animate={false} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
loading={isLazy ? 'lazy' : undefined}
|
||||
decoding="async"
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
className={clsx(size, 'aspect-video flex-shrink-0 object-cover')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface TitleBackdropFallbackProps {
|
||||
title: Title;
|
||||
episode?: Episode;
|
||||
srcSize?: ImageSize;
|
||||
size?: string;
|
||||
}
|
||||
function TitleBackdropFallback({
|
||||
title,
|
||||
episode,
|
||||
srcSize,
|
||||
size,
|
||||
}: TitleBackdropFallbackProps) {
|
||||
return (
|
||||
<TitleBackdrop
|
||||
title={title}
|
||||
episode={episode}
|
||||
srcSize={srcSize}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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