599 lines
21 KiB
TypeScript
Raw Normal View History

2025-01-06 21:08:51 +07:00
import { Link, useNavigate, useParams } from 'react-router-dom';
import loadVideoById from '../api/loader/loadVideoById';
import { Fragment, useEffect, useState } from 'react';
import { ConfigType, VideoType } from './Home';
import VideoPlayer from '../components/VideoPlayer';
import iconEye from '/img/icon-eye.svg';
import iconThumb from '/img/icon-thumb.svg';
import iconStarFull from '/img/icon-star-full.svg';
import iconStarEmpty from '/img/icon-star-empty.svg';
import iconStarHalf from '/img/icon-star-half.svg';
import iconClose from '/img/icon-close.svg';
import iconUnseen from '/img/icon-unseen.svg';
import iconSeen from '/img/icon-seen.svg';
import Routes from '../configuration/routes/RouteList';
import Linkify from '../components/Linkify';
import loadSimmilarVideosById from '../api/loader/loadSimmilarVideosById';
import VideoList from '../components/VideoList';
import updateWatchedState from '../api/actions/updateWatchedState';
import humanFileSize from '../functions/humanFileSize';
import ScrollToTopOnNavigate from '../components/ScrollToTop';
import ChannelOverview from '../components/ChannelOverview';
import deleteVideo from '../api/actions/deleteVideo';
import capitalizeFirstLetter from '../functions/capitalizeFirstLetter';
import formatDate from '../functions/formatDates';
import formatNumbers from '../functions/formatNumbers';
import queueReindex from '../api/actions/queueReindex';
import GoogleCast from '../components/GoogleCast';
import WatchedCheckBox from '../components/WatchedCheckBox';
import convertStarRating from '../functions/convertStarRating';
import loadPlaylistList from '../api/loader/loadPlaylistList';
import { PlaylistsResponseType } from './Playlists';
import PaginationDummy from '../components/PaginationDummy';
import updateCustomPlaylist from '../api/actions/updateCustomPlaylist';
import { PlaylistType } from './Playlist';
import loadCommentsbyVideoId from '../api/loader/loadCommentsbyVideoId';
import CommentBox, { CommentsType } from '../components/CommentBox';
import Button from '../components/Button';
import getApiUrl from '../configuration/getApiUrl';
import loadVideoNav, { VideoNavResponseType } from '../api/loader/loadVideoNav';
import loadIsAdmin from '../functions/getIsAdmin';
const isInPlaylist = (videoId: string, playlist: PlaylistType) => {
return playlist.playlist_entries.some(entry => {
return entry.youtube_id === videoId;
});
};
type VideoParams = {
videoId: string;
};
type PlaylistNavPreviousItemType = {
youtube_id: string;
vid_thumb: string;
idx: number;
title: string;
};
type PlaylistNavNextItemType = {
youtube_id: string;
vid_thumb: string;
idx: number;
title: string;
};
type PlaylistNavItemType = {
playlist_meta: {
current_idx: string;
playlist_id: string;
playlist_name: string;
playlist_channel: string;
};
playlist_previous: PlaylistNavPreviousItemType;
playlist_next: PlaylistNavNextItemType;
};
type PlaylistNavType = PlaylistNavItemType[];
export type SponsorBlockSegmentType = {
category: string;
actionType: string;
segment: number[];
UUID: string;
videoDuration: number;
locked: number;
votes: number;
};
export type SponsorBlockType = {
last_refresh: number;
has_unlocked: boolean;
is_enabled: boolean;
segments: SponsorBlockSegmentType[];
message?: string;
};
export type SimilarVideosResponseType = {
data: VideoType[];
config: ConfigType;
};
export type VideoResponseType = {
data: VideoType;
config: ConfigType;
};
type CommentsResponseType = {
data: CommentsType[];
config: ConfigType;
};
export type VideoCommentsResponseType = {
data: VideoType;
config: ConfigType;
playlist_nav: PlaylistNavType;
};
const Video = () => {
const { videoId } = useParams() as VideoParams;
const navigate = useNavigate();
const isAdmin = loadIsAdmin();
const [loading, setLoading] = useState(false);
const [videoEnded, setVideoEnded] = useState(false);
const [playlistAutoplay, setPlaylistAutoplay] = useState(
localStorage.getItem('playlistAutoplay') === 'true',
);
const [playlistIdForAutoplay, setPlaylistIDForAutoplay] = useState<string | undefined>(
localStorage.getItem('playlistIdForAutoplay') ?? '',
);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showAddToPlaylist, setShowAddToPlaylist] = useState(false);
const [refreshVideoList, setRefreshVideoList] = useState(false);
const [reindex, setReindex] = useState(false);
const [videoResponse, setVideoResponse] = useState<VideoResponseType>();
const [simmilarVideos, setSimmilarVideos] = useState<SimilarVideosResponseType>();
const [videoPlaylistNav, setVideoPlaylistNav] = useState<VideoNavResponseType[]>();
const [customPlaylistsResponse, setCustomPlaylistsResponse] = useState<PlaylistsResponseType>();
const [commentsResponse, setCommentsResponse] = useState<CommentsResponseType>();
useEffect(() => {
(async () => {
setLoading(true);
const videoResponse = await loadVideoById(videoId);
const simmilarVideosResponse = await loadSimmilarVideosById(videoId);
const customPlaylistsResponse = await loadPlaylistList({ type: 'custom' });
const commentsResponse = await loadCommentsbyVideoId(videoId);
const videoNavResponse = await loadVideoNav(videoId);
setVideoResponse(videoResponse);
setSimmilarVideos(simmilarVideosResponse);
setVideoPlaylistNav(videoNavResponse);
setCustomPlaylistsResponse(customPlaylistsResponse);
setCommentsResponse(commentsResponse);
setRefreshVideoList(false);
setLoading(false);
})();
}, [videoId, refreshVideoList]);
useEffect(() => {
localStorage.setItem('playlistAutoplay', playlistAutoplay.toString());
if (!playlistAutoplay) {
localStorage.setItem('playlistIdForAutoplay', '');
return;
}
localStorage.setItem('playlistIdForAutoplay', playlistIdForAutoplay || '');
}, [playlistAutoplay, playlistIdForAutoplay]);
useEffect(() => {
if (videoEnded && playlistAutoplay) {
const playlist = videoPlaylistNav?.find(playlist => {
return playlist.playlist_meta.playlist_id === playlistIdForAutoplay;
});
if (playlist) {
const nextYoutubeId = playlist.playlist_next?.youtube_id;
if (nextYoutubeId) {
setVideoEnded(false);
navigate(Routes.Video(nextYoutubeId));
}
}
}
}, [videoEnded, playlistAutoplay]);
if (videoResponse === undefined) {
return [];
}
const video = videoResponse.data;
const watched = videoResponse.data.player.watched;
const config = videoResponse.config;
const playlistNav = videoPlaylistNav;
const sponsorBlock = videoResponse.data.sponsorblock;
const customPlaylists = customPlaylistsResponse?.data;
const starRating = convertStarRating(video?.stats?.average_rating);
const comments = commentsResponse?.data;
console.log('playlistNav', playlistNav);
const cast = config.enable_cast;
return (
<>
<title>{`TA | ${video.title}`}</title>
<ScrollToTopOnNavigate />
{!loading && (
<VideoPlayer
video={videoResponse}
sponsorBlock={sponsorBlock}
autoplay={playlistAutoplay}
onVideoEnd={() => {
setVideoEnded(true);
}}
/>
)}
<div className="boxed-content">
<div className="title-bar">
{cast && (
<GoogleCast
video={video}
setRefresh={() => {
setRefreshVideoList(true);
}}
/>
)}
<h1 id="video-title">{video.title}</h1>
</div>
<div className="info-box info-box-3">
<ChannelOverview
channelId={video.channel.channel_id}
channelname={video.channel.channel_name}
channelSubs={video.channel.channel_subs}
channelSubscribed={video.channel.channel_subscribed}
channelThumbUrl={video.channel.channel_thumb_url}
setRefresh={setRefreshVideoList}
/>
<div className="info-box-item">
<div>
<p>Published: {formatDate(video.published)}</p>
<p>Last refreshed: {formatDate(video.vid_last_refresh)}</p>
<p className="video-info-watched">
Watched:
<WatchedCheckBox
watched={watched}
onClick={async status => {
await updateWatchedState({
id: videoId,
is_watched: status,
});
}}
onDone={() => {
setRefreshVideoList(true);
}}
/>
</p>
{video.active && (
<p>
Youtube:{' '}
<a href={`https://www.youtube.com/watch?v=${video.youtube_id}`} target="_blank">
Active
</a>
</p>
)}
{!video.active && <p>Youtube: Deactivated</p>}
</div>
</div>
<div className="info-box-item">
<div>
<p className="thumb-icon">
<img src={iconEye} alt="views" />: {formatNumbers(video.stats.view_count)}
</p>
<p className="thumb-icon like">
<img src={iconThumb} alt="thumbs-up" />: {formatNumbers(video.stats.like_count)}
</p>
{video.stats.dislike_count > 0 && (
<p className="thumb-icon">
<img className="dislike" src={iconThumb} alt="thumbs-down" />:{' '}
{video.stats.dislike_count}
</p>
)}
{video?.stats && starRating && (
<div className="rating-stars">
{starRating?.map?.((star, index) => {
if (star === 'full') {
return <img key={index} src={iconStarFull} alt={star} />;
}
if (star === 'half') {
return <img key={index} src={iconStarHalf} alt={star} />;
}
return <img key={index} src={iconStarEmpty} alt={star} />;
})}
</div>
)}
</div>
</div>
</div>
<div className="info-box info-box-2">
<div className="info-box-item">
<div className="button-box">
{reindex && <p>Reindex scheduled</p>}
{!reindex && (
<>
{isAdmin && (
<div id="reindex-button" className="button-box">
<Button
label="Reindex"
title={`Reindex ${video.title}`}
onClick={async () => {
await queueReindex(video.youtube_id, 'video');
setReindex(true);
}}
/>
</div>
)}
</>
)}
<a download="" href={`${getApiUrl()}${video.media_url}`}>
<Button label="Download File" id="download-item" />
</a>{' '}
{isAdmin && (
<>
{!showDeleteConfirm && (
<Button
label="Delete Video"
id="delete-item"
onClick={() => setShowDeleteConfirm(!showDeleteConfirm)}
/>
)}
{showDeleteConfirm && (
<div className="delete-confirm" id="delete-button">
<span>Are you sure? </span>
<Button
label="Delete"
className="danger-button"
onClick={async () => {
await deleteVideo(videoId);
navigate(Routes.Channel(video.channel.channel_id));
}}
/>{' '}
<Button
label="Cancel"
onClick={() => setShowDeleteConfirm(!showDeleteConfirm)}
/>
</div>
)}
</>
)}{' '}
{!showAddToPlaylist && (
<Button
label="Add To Playlist"
id={`${video.youtube_id}-button`}
data-id={video.youtube_id}
data-context="video"
onClick={() => {
setShowAddToPlaylist(true);
}}
/>
)}
{showAddToPlaylist && (
<>
<div className="video-popup-menu">
<img
src={iconClose}
className="video-popup-menu-close-button"
title="Close menu"
onClick={() => {
setShowAddToPlaylist(false);
}}
/>
<h3>Add video to...</h3>
{customPlaylists?.map(playlist => {
return (
<p
onClick={async () => {
if (isInPlaylist(videoId, playlist)) {
await updateCustomPlaylist('remove', playlist.playlist_id, videoId);
} else {
await updateCustomPlaylist('create', playlist.playlist_id, videoId);
}
setRefreshVideoList(true);
}}
>
{isInPlaylist(videoId, playlist) && (
<img className="p-button" src={iconSeen} />
)}
{!isInPlaylist(videoId, playlist) && (
<img className="p-button" src={iconUnseen} />
)}
{playlist.playlist_name}
</p>
);
})}
<p>
<Link to={Routes.Playlists}>Create playlist</Link>
</p>
</div>
</>
)}
</div>
</div>
<div className="info-box-item">
{video.media_size && <p>File size: {humanFileSize(video.media_size)}</p>}
{video.streams &&
video.streams.map(stream => {
return (
<p key={stream.index}>
{capitalizeFirstLetter(stream.type)}: {stream.codec}{' '}
{humanFileSize(stream.bitrate)}/s
{stream.width && (
<>
<span className="space-carrot">|</span> {stream.width}x{stream.height}
</>
)}{' '}
</p>
);
})}
</div>
</div>
{video.tags && video.tags.length > 0 && (
<div className="description-box">
<div className="video-tag-box">
{video.tags.map(tag => {
return (
<span key={tag} className="video-tag">
{tag}
</span>
);
})}
</div>
</div>
)}
{video.description && (
<div className="description-box">
<p
id={descriptionExpanded ? 'text-expand-expanded' : 'text-expand'}
className="description-text"
>
<Linkify>{video.description}</Linkify>
</p>
<Button
label={descriptionExpanded ? 'Show less' : 'Show more'}
id="text-expand-button"
onClick={() => setDescriptionExpanded(!descriptionExpanded)}
/>
</div>
)}
{playlistNav && (
<>
{playlistNav.map(playlistItem => {
return (
<div key={playlistItem.playlist_meta.playlist_id} className="playlist-wrap">
<Link to={Routes.Playlist(playlistItem.playlist_meta.playlist_id)}>
<h3>
Playlist [{playlistItem.playlist_meta.current_idx + 1}
]: {playlistItem.playlist_meta.playlist_name}
</h3>
</Link>
<div className="toggle">
<p>Autoplay:</p>
<div className="toggleBox">
<input
checked={playlistAutoplay}
onChange={() => {
if (!playlistAutoplay) {
setPlaylistIDForAutoplay(playlistItem.playlist_meta.playlist_id);
}
setPlaylistAutoplay(!playlistAutoplay);
}}
type="checkbox"
/>
{!playlistAutoplay && (
<label htmlFor="" className="ofbtn">
Off
</label>
)}
{playlistAutoplay && (
<label htmlFor="" className="onbtn">
On
</label>
)}
</div>
</div>
<div className="playlist-nav">
<div className="playlist-nav-item">
{playlistItem.playlist_previous && (
<>
<Link to={Routes.Video(playlistItem.playlist_previous.youtube_id)}>
<img
src={`${getApiUrl()}/${playlistItem.playlist_previous.vid_thumb}`}
alt="previous thumbnail"
/>
</Link>
<div className="playlist-desc">
<p>Previous:</p>
<Link to={Routes.Video(playlistItem.playlist_previous.youtube_id)}>
<h3>
[{playlistItem.playlist_previous.idx + 1}]{' '}
{playlistItem.playlist_previous.title}
</h3>
</Link>
</div>
</>
)}
</div>
<div className="playlist-nav-item">
{playlistItem.playlist_next && (
<>
<div className="playlist-desc">
<p>Next:</p>
<Link to={Routes.Video(playlistItem.playlist_next.youtube_id)}>
<h3>
[{playlistItem.playlist_next.idx + 1}]{' '}
{playlistItem.playlist_next.title}
</h3>
</Link>
</div>
<Link to={Routes.Video(playlistItem.playlist_next.youtube_id)}>
<img
src={`${getApiUrl()}/${playlistItem.playlist_next.vid_thumb}`}
alt="previous thumbnail"
/>
</Link>
</>
)}
</div>
</div>
</div>
);
})}
</>
)}
<div className="description-box">
<h3>Similar Videos</h3>
<div className="video-list grid grid-3" id="similar-videos">
<VideoList
videoList={simmilarVideos?.data}
viewLayout="grid"
refreshVideoList={setRefreshVideoList}
/>
</div>
</div>
{video.comment_count == 0 && (
<div className="comments-section">
<span>Video has no comments</span>
</div>
)}
{video.comment_count && (
<div className="comments-section">
<h3>Comments: {video.comment_count}</h3>
<div id="comments-list" className="comments-list">
{comments?.map(comment => {
return (
<Fragment key={comment.comment_id}>
<CommentBox comment={comment} />
</Fragment>
);
})}
</div>
</div>
)}
<div className="boxed-content-empty" />
</div>
<PaginationDummy />
</>
);
};
export default Video;