|
|
"use client"; |
|
|
import { useEffect, useRef, useState } from "react"; |
|
|
import { useRouter } from "next/navigation"; |
|
|
import "./Player.css"; |
|
|
import { Spinner } from "@/components/shared/Spinner/Spinner"; |
|
|
import SeekableProgressBar from "@/components/shared/ProgressBar/SeekableProgressBar"; |
|
|
import { |
|
|
getFileNameWithoutExtension, |
|
|
formatTime, |
|
|
getStorageKey, |
|
|
} from "./utils"; |
|
|
|
|
|
export default function Player({ videoUrl, title, type, episode = null, videoThumbnail=null }) { |
|
|
const router = useRouter(); |
|
|
const videoRef = useRef(null); |
|
|
const [isPlaying, setIsPlaying] = useState(false); |
|
|
const [volume, setVolume] = useState(1); |
|
|
const [isMuted, setIsMuted] = useState(false); |
|
|
const [progress, setProgress] = useState(0); |
|
|
const [buffer, setBuffer] = useState(0); |
|
|
const [isFullscreen, setIsFullscreen] = useState(false); |
|
|
const [showControls, setShowControls] = useState(true); |
|
|
const [isBuffering, setIsBuffering] = useState(true); |
|
|
const overlayTimeout = useRef(null); |
|
|
const [contextMenu, setContextMenu] = useState({ |
|
|
visible: false, |
|
|
x: 0, |
|
|
y: 0, |
|
|
}); |
|
|
const playerVersion = "0.0.4 Alpha"; |
|
|
const seekTime = 5; |
|
|
|
|
|
useEffect(() => { |
|
|
if ('mediaSession' in navigator) { |
|
|
navigator.mediaSession.metadata = new MediaMetadata({ |
|
|
title: episode? episode: title, |
|
|
artwork: [ |
|
|
{ src: videoThumbnail, sizes: '680x1000', type: 'image/png' } |
|
|
] |
|
|
}); |
|
|
|
|
|
navigator.mediaSession.setActionHandler('play', () => { |
|
|
videoRef.current.play(); |
|
|
}); |
|
|
navigator.mediaSession.setActionHandler('pause', () => { |
|
|
videoRef.current.pause(); |
|
|
}); |
|
|
navigator.mediaSession.setActionHandler('seekbackward', (details) => { |
|
|
videoRef.current.currentTime = Math.max(videoRef.current.currentTime - (details.seekOffset || 10), 0); |
|
|
}); |
|
|
navigator.mediaSession.setActionHandler('seekforward', (details) => { |
|
|
videoRef.current.currentTime = Math.min(videoRef.current.currentTime + (details.seekOffset || 10), videoRef.current.duration); |
|
|
}); |
|
|
navigator.mediaSession.setActionHandler('stop', () => { |
|
|
videoRef.current.pause(); |
|
|
videoRef.current.currentTime = 0; |
|
|
}); |
|
|
} |
|
|
}, [title, videoThumbnail]); |
|
|
|
|
|
useEffect(() => { |
|
|
const videoElement = videoRef.current; |
|
|
const savedData = localStorage.getItem(getStorageKey(type, title, episode)); |
|
|
if (savedData) { |
|
|
const { currentTime, duration } = JSON.parse(savedData); |
|
|
videoElement.currentTime = parseFloat(currentTime); |
|
|
} |
|
|
|
|
|
const handlePlay = () => setIsPlaying(true); |
|
|
const handlePause = () => setIsPlaying(false); |
|
|
const handleTimeUpdate = () => { |
|
|
const duration = videoElement.duration; |
|
|
const currentTime = videoElement.currentTime; |
|
|
setProgress((currentTime / duration) * 100); |
|
|
localStorage.setItem( |
|
|
getStorageKey(type, title, episode), |
|
|
JSON.stringify({ currentTime, duration }) |
|
|
); |
|
|
updateBuffer(); |
|
|
}; |
|
|
const handleWaiting = () => setIsBuffering(true); |
|
|
const handlePlaying = () => setIsBuffering(false); |
|
|
const handleProgress = () => updateBuffer(); |
|
|
const handleLoadedData = () => { |
|
|
setIsBuffering(false); |
|
|
const duration = videoElement.duration; |
|
|
const currentTime = videoElement.currentTime; |
|
|
localStorage.setItem( |
|
|
getStorageKey(type, title, episode), |
|
|
JSON.stringify({ currentTime, duration }) |
|
|
); |
|
|
}; |
|
|
const handleCanPlayThrough = () => setIsBuffering(false); |
|
|
|
|
|
videoElement.addEventListener("play", handlePlay); |
|
|
videoElement.addEventListener("pause", handlePause); |
|
|
videoElement.addEventListener("timeupdate", handleTimeUpdate); |
|
|
videoElement.addEventListener("waiting", handleWaiting); |
|
|
videoElement.addEventListener("playing", handlePlaying); |
|
|
videoElement.addEventListener("progress", handleProgress); |
|
|
videoElement.addEventListener("loadeddata", handleLoadedData); |
|
|
videoElement.addEventListener("canplaythrough", handleCanPlayThrough); |
|
|
|
|
|
return () => { |
|
|
videoElement.removeEventListener("play", handlePlay); |
|
|
videoElement.removeEventListener("pause", handlePause); |
|
|
videoElement.removeEventListener("timeupdate", handleTimeUpdate); |
|
|
videoElement.removeEventListener("waiting", handleWaiting); |
|
|
videoElement.removeEventListener("playing", handlePlaying); |
|
|
videoElement.removeEventListener("progress", handleProgress); |
|
|
videoElement.removeEventListener("loadeddata", handleLoadedData); |
|
|
videoElement.removeEventListener("canplaythrough", handleCanPlayThrough); |
|
|
}; |
|
|
}, []); |
|
|
|
|
|
useEffect(() => { |
|
|
if (showControls) { |
|
|
if (overlayTimeout.current) { |
|
|
clearTimeout(overlayTimeout.current); |
|
|
} |
|
|
overlayTimeout.current = setTimeout(() => setShowControls(false), 3000); |
|
|
} |
|
|
|
|
|
return () => clearTimeout(overlayTimeout.current); |
|
|
}, [showControls]); |
|
|
|
|
|
useEffect(() => { |
|
|
const handleKeyDown = (event) => { |
|
|
const videoElement = videoRef.current; |
|
|
switch (event.key) { |
|
|
case " ": |
|
|
event.preventDefault(); |
|
|
togglePlayPause(); |
|
|
break; |
|
|
case "ArrowRight": |
|
|
handleFastForward(); |
|
|
break; |
|
|
case "ArrowLeft": |
|
|
handleRewind(); |
|
|
break; |
|
|
case "ArrowUp": |
|
|
changeVolume(0.1); |
|
|
break; |
|
|
case "ArrowDown": |
|
|
changeVolume(-0.1); |
|
|
break; |
|
|
case "m": |
|
|
toggleMute(); |
|
|
break; |
|
|
case "f": |
|
|
toggleFullscreen(); |
|
|
break; |
|
|
case "Home": |
|
|
videoElement.currentTime = 0; |
|
|
break; |
|
|
case "End": |
|
|
videoElement.currentTime = videoElement.duration; |
|
|
break; |
|
|
case "j": |
|
|
changePlaybackRate(-0.1); |
|
|
break; |
|
|
case "l": |
|
|
changePlaybackRate(0.1); |
|
|
break; |
|
|
case "k": |
|
|
resetPlaybackRate(); |
|
|
break; |
|
|
case "c": |
|
|
toggleCaptions(); |
|
|
break; |
|
|
case "s": |
|
|
toggleControls(); |
|
|
break; |
|
|
default: |
|
|
break; |
|
|
} |
|
|
}; |
|
|
|
|
|
window.addEventListener("keydown", handleKeyDown); |
|
|
return () => { |
|
|
window.removeEventListener("keydown", handleKeyDown); |
|
|
}; |
|
|
}, [isPlaying, volume, isMuted, isFullscreen]); |
|
|
|
|
|
const changePlaybackRate = (amount) => { |
|
|
const newRate = Math.min( |
|
|
2, |
|
|
Math.max(0.5, videoRef.current.playbackRate + amount) |
|
|
); |
|
|
videoRef.current.playbackRate = newRate; |
|
|
}; |
|
|
|
|
|
const resetPlaybackRate = () => { |
|
|
videoRef.current.playbackRate = 1; |
|
|
}; |
|
|
|
|
|
const toggleCaptions = () => { |
|
|
const tracks = videoRef.current.textTracks; |
|
|
for (let i = 0; i < tracks.length; i++) { |
|
|
tracks[i].mode = tracks[i].mode === "showing" ? "hidden" : "showing"; |
|
|
} |
|
|
}; |
|
|
|
|
|
const toggleControls = () => { |
|
|
setShowControls((prevShowControls) => !prevShowControls); |
|
|
}; |
|
|
|
|
|
const changeVolume = (amount) => { |
|
|
let newVolume = Math.min(1, Math.max(0, volume + amount)); |
|
|
setVolume(newVolume); |
|
|
videoRef.current.volume = newVolume; |
|
|
setIsMuted(newVolume === 0); |
|
|
}; |
|
|
|
|
|
const handleContextMenu = (event) => { |
|
|
event.preventDefault(); |
|
|
setContextMenu({ |
|
|
visible: true, |
|
|
x: event.pageX, |
|
|
y: event.pageY, |
|
|
}); |
|
|
}; |
|
|
|
|
|
const hideContextMenu = () => { |
|
|
setContextMenu({ visible: false, x: 0, y: 0 }); |
|
|
}; |
|
|
|
|
|
useEffect(() => { |
|
|
window.addEventListener("click", hideContextMenu); |
|
|
return () => { |
|
|
window.removeEventListener("click", hideContextMenu); |
|
|
}; |
|
|
}, []); |
|
|
|
|
|
const handleFastForward = () => { |
|
|
if (videoRef.current) { |
|
|
videoRef.current.currentTime = Math.min( |
|
|
videoRef.current.duration, |
|
|
videoRef.current.currentTime + seekTime |
|
|
); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleRewind = () => { |
|
|
if (videoRef.current) { |
|
|
videoRef.current.currentTime = Math.max( |
|
|
0, |
|
|
videoRef.current.currentTime - seekTime |
|
|
); |
|
|
} |
|
|
}; |
|
|
|
|
|
const togglePlayPause = () => { |
|
|
if (isPlaying) { |
|
|
videoRef.current.pause(); |
|
|
videoRef.current.classList.remove("playing"); |
|
|
videoRef.current.classList.add("paused"); |
|
|
setShowControls(true); |
|
|
} else { |
|
|
videoRef.current.play(); |
|
|
videoRef.current.classList.remove("paused"); |
|
|
videoRef.current.classList.add("playing"); |
|
|
setShowControls(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleVolumeChange = (event) => { |
|
|
let volumeValue = parseFloat(event.target.value); |
|
|
|
|
|
|
|
|
if (!isFinite(volumeValue) || volumeValue < 0) { |
|
|
volumeValue = 0; |
|
|
} else if (volumeValue > 1) { |
|
|
volumeValue = 1; |
|
|
} |
|
|
|
|
|
setVolume(volumeValue); |
|
|
if (videoRef.current) { |
|
|
videoRef.current.volume = volumeValue; |
|
|
} |
|
|
setIsMuted(volumeValue === 0); |
|
|
}; |
|
|
|
|
|
const toggleMute = () => { |
|
|
if (isMuted) { |
|
|
videoRef.current.volume = volume; |
|
|
setIsMuted(false); |
|
|
} else { |
|
|
videoRef.current.volume = 0; |
|
|
setIsMuted(true); |
|
|
} |
|
|
}; |
|
|
|
|
|
const toggleFullscreen = () => { |
|
|
const doc = window.document; |
|
|
const docEl = doc.documentElement; |
|
|
|
|
|
const requestFullscreen = |
|
|
docEl.requestFullscreen || |
|
|
docEl.mozRequestFullScreen || |
|
|
docEl.webkitRequestFullscreen || |
|
|
docEl.msRequestFullscreen; |
|
|
const exitFullscreen = |
|
|
doc.exitFullscreen || |
|
|
doc.mozCancelFullScreen || |
|
|
docEl.webkitExitFullscreen || |
|
|
doc.msExitFullscreen; |
|
|
|
|
|
if (!isFullscreen) { |
|
|
requestFullscreen.call(docEl); |
|
|
} else { |
|
|
exitFullscreen.call(doc); |
|
|
} |
|
|
|
|
|
setIsFullscreen(!isFullscreen); |
|
|
}; |
|
|
|
|
|
const handleSeek = (newProgress) => { |
|
|
videoRef.current.currentTime = |
|
|
(newProgress / 100) * videoRef.current.duration; |
|
|
setProgress(newProgress); |
|
|
}; |
|
|
|
|
|
const handleMouseMove = () => { |
|
|
setShowControls(true); |
|
|
}; |
|
|
|
|
|
const updateBuffer = () => { |
|
|
const videoElement = videoRef.current; |
|
|
if (videoElement.buffered.length > 0) { |
|
|
const bufferEnd = videoElement.buffered.end( |
|
|
videoElement.buffered.length - 1 |
|
|
); |
|
|
const bufferValue = (bufferEnd / videoElement.duration) * 100; |
|
|
setBuffer(bufferValue); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleExitClick = () => { |
|
|
router.back(); |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div |
|
|
className="video-player-container" |
|
|
onMouseMove={handleMouseMove} |
|
|
onContextMenu={handleContextMenu} |
|
|
> |
|
|
<div className="player-indication-overlay"></div> |
|
|
<div className={`player-overlay ${showControls ? "show" : "hide"}`}> |
|
|
<div className="video-title"> |
|
|
<div className="control-btn player-exit" onClick={handleExitClick}> |
|
|
<span className="material-symbols-outlined medium"> |
|
|
keyboard_tab_rtl |
|
|
</span> |
|
|
</div> |
|
|
<label className="video-title-text"> |
|
|
{episode ? getFileNameWithoutExtension(episode) : title} |
|
|
</label> |
|
|
</div> |
|
|
<div className="player-controls-center"> |
|
|
<button onClick={handleRewind} className="control-btn"> |
|
|
<label className="rewind-label-left">{seekTime}s</label> |
|
|
<span className="material-symbols-outlined large">rotate_left</span> |
|
|
</button> |
|
|
<button onClick={togglePlayPause} className="play-pause-btn"> |
|
|
{isPlaying ? ( |
|
|
<span className="material-symbols-outlined large">pause</span> |
|
|
) : ( |
|
|
<span className="material-symbols-outlined large"> |
|
|
play_arrow |
|
|
</span> |
|
|
)} |
|
|
</button> |
|
|
<button onClick={handleFastForward} className="control-btn"> |
|
|
<span className="material-symbols-outlined large"> |
|
|
rotate_right |
|
|
</span> |
|
|
<label className="rewind-label-right">{seekTime}s</label> |
|
|
</button> |
|
|
</div> |
|
|
<div className="controls"> |
|
|
<div className="player-controls-top"> |
|
|
<label className="current-time"> |
|
|
{formatTime(videoRef?.current?.currentTime)} |
|
|
</label> |
|
|
<SeekableProgressBar |
|
|
progress={progress} |
|
|
buffer={buffer} |
|
|
onSeek={handleSeek} |
|
|
/> |
|
|
<label className="duration"> |
|
|
{formatTime(videoRef?.current?.duration)} |
|
|
</label> |
|
|
</div> |
|
|
<div className="player-controls-down"> |
|
|
<div className="player-controls-left"> |
|
|
<button onClick={toggleMute} className="control-btn volumn-btn"> |
|
|
{isMuted ? ( |
|
|
<span className="material-symbols-outlined small"> |
|
|
volume_off |
|
|
</span> |
|
|
) : ( |
|
|
<span className="material-symbols-outlined small"> |
|
|
volume_up |
|
|
</span> |
|
|
)} |
|
|
</button> |
|
|
<input |
|
|
type="range" |
|
|
className="volume-control" |
|
|
min="0" |
|
|
max="1" |
|
|
step="0.01" |
|
|
value={volume} |
|
|
onChange={handleVolumeChange} |
|
|
/> |
|
|
<button |
|
|
onClick={handleRewind} |
|
|
className="previous-btn control-btn" |
|
|
> |
|
|
<span className="material-symbols-outlined small"> |
|
|
skip_previous |
|
|
</span> |
|
|
</button> |
|
|
|
|
|
<button |
|
|
onClick={handleFastForward} |
|
|
className="next-btn control-btn" |
|
|
> |
|
|
<span className="material-symbols-outlined small"> |
|
|
skip_next |
|
|
</span> |
|
|
</button> |
|
|
{isPlaying ? ( |
|
|
<button className="playlist-btn control-btn"> |
|
|
<span className="material-symbols-outlined small"> |
|
|
featured_play_list |
|
|
</span> |
|
|
</button> |
|
|
) : ( |
|
|
<button className="playlist-btn control-btn" disabled={true}> |
|
|
<span className="material-symbols-outlined small"> |
|
|
featured_play_list |
|
|
</span> |
|
|
</button> |
|
|
)} |
|
|
</div> |
|
|
<div className="player-controls-right"> |
|
|
{isPlaying ? ( |
|
|
<button className="cc-btn control-btn"> |
|
|
<span className="material-symbols-outlined small"> |
|
|
closed_caption |
|
|
</span> |
|
|
</button> |
|
|
) : ( |
|
|
<button className="cc-btn control-btn" disabled={true}> |
|
|
<span className="material-symbols-outlined small"> |
|
|
closed_caption_disabled |
|
|
</span> |
|
|
</button> |
|
|
)} |
|
|
<button onClick={toggleFullscreen} className="control-btn"> |
|
|
{isFullscreen ? ( |
|
|
<span className="material-symbols-outlined small"> |
|
|
fullscreen_exit |
|
|
</span> |
|
|
) : ( |
|
|
<span className="material-symbols-outlined small"> |
|
|
fullscreen |
|
|
</span> |
|
|
)} |
|
|
</button> |
|
|
{/* {videoUrl && ( |
|
|
<a href={videoUrl} download className="control-btn"> |
|
|
<FontAwesomeIcon icon={faDownload} size="xl" /> |
|
|
</a> |
|
|
)} */} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<video |
|
|
ref={videoRef} |
|
|
className="video-element" |
|
|
controls={false} |
|
|
src={videoUrl} |
|
|
autoPlay={true} |
|
|
> |
|
|
{/* <track |
|
|
kind="subtitles" |
|
|
label="English" |
|
|
srcLang="en" |
|
|
src="/My.Spy.The.Eternal.City.(2024).WEB.vtt" |
|
|
default |
|
|
/> */} |
|
|
Your browser does not support the video tag. |
|
|
</video> |
|
|
{isBuffering && ( |
|
|
<div className="buffering-indicator"> |
|
|
<div className="postion-fix"> |
|
|
<Spinner /> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
{contextMenu.visible && ( |
|
|
<div |
|
|
className="context-menu" |
|
|
style={{ left: contextMenu.x, top: contextMenu.y }} |
|
|
> |
|
|
<ul> |
|
|
<li>Player Version: {playerVersion}</li> |
|
|
</ul> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|