import updateVideoProgressById from '../api/actions/updateVideoProgressById'; import { SponsorBlockSegmentType, SponsorBlockType } from '../pages/Video'; import { Dispatch, Fragment, SetStateAction, SyntheticEvent, useEffect, useRef, useState, } from 'react'; import formatTime from '../functions/formatTime'; import { useSearchParams } from 'react-router-dom'; import getApiUrl from '../configuration/getApiUrl'; import { useKeyPress } from '../functions/useKeypressHook'; import { VideoResponseType } from '../api/loader/loadVideoById'; const VIDEO_PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 2.75, 3]; type VideoTag = SyntheticEvent; export type SkippedSegmentType = { from: number; to: number; }; export type SponsorSegmentsSkippedType = Record; type Subtitle = { name: string; source: string; lang: string; media_url: string; }; type SubtitlesProp = { subtitles: Subtitle[]; }; const Subtitles = ({ subtitles }: SubtitlesProp) => { return subtitles.map((subtitle: Subtitle) => { let label = subtitle.name; if (subtitle.source === 'auto') { label += ' - auto'; } return ( ); }); }; const handleTimeUpdate = ( youtubeId: string, watched: boolean, sponsorBlock?: SponsorBlockType, setSponsorSegmentSkipped?: Dispatch>, onWatchStateChanged?: (status: boolean) => void, ) => async (videoTag: VideoTag) => { const currentTime = Number(videoTag.currentTarget.currentTime); if (sponsorBlock && sponsorBlock.segments) { sponsorBlock.segments.forEach((segment: SponsorBlockSegmentType) => { const actionType = segment.actionType; const doSkip = actionType == 'skip'; const [from, to] = segment.segment; if (doSkip && currentTime >= from && currentTime <= from + 0.3) { videoTag.currentTarget.currentTime = to; setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => { return { ...segments, [segment.UUID]: { from, to } }; }); } if (currentTime > to + 10) { setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => { return { ...segments, [segment.UUID]: { from: 0, to: 0 } }; }); } }); } if (currentTime < 10 && currentTime === Number(videoTag.currentTarget.duration)) return; if (Number((currentTime % 10).toFixed(1)) <= 0.2) { // Check progress every 10 seconds or else progress is checked a few times a second const videoProgressResponse = await updateVideoProgressById({ youtubeId, currentProgress: currentTime, }); const { data: videoProgressResponseData } = videoProgressResponse ?? {}; if (videoProgressResponseData?.watched && watched !== videoProgressResponseData.watched) { onWatchStateChanged?.(true); } } }; type VideoPlayerProps = { video: VideoResponseType; sponsorBlock?: SponsorBlockType; embed?: boolean; autoplay?: boolean; onWatchStateChanged?: (status: boolean) => void; onVideoEnd?: () => void; seekToTimestamp?: number; setSeekToTimestamp?: (timestamp: number | undefined) => void; }; const VideoPlayer = ({ video, sponsorBlock, embed, autoplay = false, onWatchStateChanged, onVideoEnd, seekToTimestamp, setSeekToTimestamp, }: VideoPlayerProps) => { const videoRef = useRef(null); useEffect(() => { if (seekToTimestamp === undefined || !videoRef.current) { return; } const videoDuration = videoRef.current.duration; if (isNaN(videoDuration) || seekToTimestamp > videoDuration) { return; } videoRef.current.currentTime = seekToTimestamp; if (videoRef.current.paused) { videoRef.current.play(); } if (setSeekToTimestamp) setSeekToTimestamp(undefined); window.scroll(0, 0); // eslint-disable-next-line react-hooks/exhaustive-deps }, [seekToTimestamp]); const [searchParams] = useSearchParams(); const searchParamVideoProgress = searchParams.get('t'); const volumeFromStorage = Number(localStorage.getItem('playerVolume') ?? 1); const playBackSpeedFromStorage = Number(localStorage.getItem('playerSpeed') || 1); const playBackSpeedIndex = VIDEO_PLAYBACK_SPEEDS.indexOf(playBackSpeedFromStorage) !== -1 ? VIDEO_PLAYBACK_SPEEDS.indexOf(playBackSpeedFromStorage) : 3; const [skippedSegments, setSkippedSegments] = useState({}); const [isMuted, setIsMuted] = useState(false); const [playbackSpeedIndex, setPlaybackSpeedIndex] = useState(playBackSpeedIndex); const [lastSubtitleTack, setLastSubtitleTack] = useState(0); const [showHelpDialog, setShowHelpDialog] = useState(false); const [showInfoDialog, setShowInfoDialog] = useState(false); const [infoDialogContent, setInfoDialogContent] = useState(''); const [isTheaterMode, setIsTheaterMode] = useState(false); const videoId = video.youtube_id; const videoUrl = video.media_url; const videoThumbUrl = video.vid_thumb_url; const watched = video.player.watched; const duration = video.player.duration; const videoSubtitles = video.subtitles; let videoSrcProgress = Number(video.player?.position) > 0 ? Number(video.player?.position) : ''; if (searchParamVideoProgress !== null) { videoSrcProgress = searchParamVideoProgress; } const infoDialog = (content: string) => { setInfoDialogContent(content); setShowInfoDialog(true); setTimeout(() => { setShowInfoDialog(false); setInfoDialogContent(''); }, 500); }; useKeyPress('m', () => { setIsMuted(current => !current); }); useKeyPress('p', () => { if (videoRef.current?.paused) { videoRef.current.play(); } else { videoRef.current?.pause(); } }); useKeyPress('>', () => { const newSpeed = playbackSpeedIndex + 1; if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeed]) { const speed = VIDEO_PLAYBACK_SPEEDS[newSpeed]; videoRef.current.playbackRate = speed; setPlaybackSpeedIndex(newSpeed); infoDialog(`${speed}x`); } }); useKeyPress('<', () => { const newSpeedIndex = playbackSpeedIndex - 1; if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]) { const speed = VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]; videoRef.current.playbackRate = speed; setPlaybackSpeedIndex(newSpeedIndex); infoDialog(`${speed}x`); } }); useKeyPress('=', () => { const newSpeedIndex = 3; if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]) { const speed = VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]; videoRef.current.playbackRate = speed; setPlaybackSpeedIndex(newSpeedIndex); infoDialog(`${speed}x`); } }); useKeyPress('f', () => { if (videoRef.current && videoRef.current.requestFullscreen && !document.fullscreenElement) { videoRef.current.requestFullscreen().catch(e => { console.error(e); infoDialog('Unable to enter fullscreen'); }); } else { document.exitFullscreen().catch(e => { console.error(e); infoDialog('Unable to exit fullscreen'); }); } }); useKeyPress('c', () => { if (!videoRef.current) { return; } const tracks = [...videoRef.current.textTracks]; if (tracks.length === 0) { return; } const lastIndex = tracks.findIndex(x => x.mode === 'showing'); const active = tracks[lastIndex]; if (!active && lastSubtitleTack !== 0) { tracks[lastSubtitleTack - 1].mode = 'showing'; } else if (active) { active.mode = 'hidden'; setLastSubtitleTack(lastIndex + 1); } }); useKeyPress('ArrowLeft', () => { const currentCurrentTime = videoRef.current?.currentTime; if (currentCurrentTime !== undefined && videoRef.current) { infoDialog('- 5 seconds'); videoRef.current.currentTime = currentCurrentTime - 5; } }); useKeyPress('ArrowRight', () => { const currentCurrentTime = videoRef.current?.currentTime; if (currentCurrentTime !== undefined && videoRef.current) { infoDialog('+ 5 seconds'); videoRef.current.currentTime = currentCurrentTime + 5; } }); useKeyPress('?', () => { setShowHelpDialog(current => { const next = !current; if (next) { setTimeout(() => { setShowHelpDialog(false); }, 3000); } return next; }); }); useKeyPress('t', () => { if (embed) { return; } setIsTheaterMode(current => { const next = !current; infoDialog(next ? 'Theater mode' : 'Normal mode'); return next; }); }); useKeyPress('Escape', () => { if (embed) { return; } setIsTheaterMode(current => { if (!current) { return current; } infoDialog('Normal mode'); return false; }); }); const handleVideoEnd = ( youtubeId: string, watched: boolean, setSponsorSegmentSkipped?: Dispatch>, ) => async (videoTag: VideoTag) => { const currentTime = Number(videoTag.currentTarget.currentTime); const videoProgressResponse = await updateVideoProgressById({ youtubeId, currentProgress: currentTime, }); const { data: videoProgressResponseData } = videoProgressResponse; if (videoProgressResponseData?.watched && watched !== videoProgressResponseData.watched) { onWatchStateChanged?.(true); } setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => { const keys = Object.keys(segments); keys.forEach(uuid => { segments[uuid] = { from: 0, to: 0 }; }); return segments; }); onVideoEnd?.(); }; return ( <>
{!embed && ( <> )}
Show help ?
Toggle pause play p
Toggle mute m
Toggle fullscreen f
Toggle theater mode t
Exit theater mode Esc
Toggle subtitles (if available) c
Increase speed >
Decrease speed <
Reset speed =
Back 5 seconds
Forward 5 seconds
{infoDialogContent}
{sponsorBlock?.is_enabled && ( <> {sponsorBlock.segments.length == 0 && (

This video doesn't have any sponsor segments added. To add a segment go to{' '} this video on YouTube {' '} and add a segment using the{' '} SponsorBlock {' '} extension.

)} {sponsorBlock.has_unlocked && (

This video has unlocked sponsor segments. Go to{' '} this video on YouTube {' '} and vote on the segments using the{' '} SponsorBlock {' '} extension.

)} {Object.values(skippedSegments).map(({ from, to }, index) => { return ( {from !== 0 && to !== 0 && (

Skipped sponsor segment from {formatTime(from)} to {formatTime(to)}.

)}
); })} )}
); }; export default VideoPlayer;