diff options
| author | Pinapelz <yukais@pinapelz.com> | 2023-11-15 01:41:32 -0800 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2023-11-15 01:41:32 -0800 |
| commit | 782159c7a965203f4f134dabe13634e59b579cc7 (patch) | |
| tree | a5ef7443a0bc81e097ae1eac61ec509a078a081f | |
| parent | 71b680fb9d29057b97748c54d1ad20229fe3394c (diff) | |
feat: add support for srv3
| -rw-r--r-- | package.json | 3 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 49 | ||||
| -rw-r--r-- | src/app/App.tsx | 288 | ||||
| -rw-r--r-- | src/app/components/VideoControls.tsx | 153 |
4 files changed, 456 insertions, 37 deletions
diff --git a/package.json b/package.json index d6858a3..971e5bf 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,14 @@ "lint": "next lint" }, "dependencies": { + "font-awesome": "^4.7.0", "next": "14.0.2", "react": "^18", "react-bootstrap": "^2.9.1", "react-dom": "^18", + "react-icons": "^4.12.0", "react-lrc": "^3.0.2", + "react-srv3": "^1.0.4", "react-toastify": "^9.1.3", "styled-components": "^6.1.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90dcfe9..ae7b0c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + font-awesome: + specifier: ^4.7.0 + version: 4.7.0 next: specifier: 14.0.2 version: 14.0.2(react-dom@18.2.0)(react@18.2.0) @@ -17,9 +20,15 @@ dependencies: react-dom: specifier: ^18 version: 18.2.0(react@18.2.0) + react-icons: + specifier: ^4.12.0 + version: 4.12.0(react@18.2.0) react-lrc: specifier: ^3.0.2 version: 3.0.2(react-dom@18.2.0)(react@18.2.0) + react-srv3: + specifier: ^1.0.4 + version: 1.0.4(react@18.2.0) react-toastify: specifier: ^9.1.3 version: 9.1.3(react-dom@18.2.0)(react@18.2.0) @@ -1287,6 +1296,13 @@ packages: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} dev: true + /fast-xml-parser@3.21.1: + resolution: {integrity: sha512-FTFVjYoBOZTJekiUsawGsSYV9QL0A+zDYCRj7y34IO6Jg+2IMYEtQa+bbictpdpV8dHxXywqU7C0gRDEOFtBFg==} + hasBin: true + dependencies: + strnum: 1.0.5 + dev: false + /fastq@1.15.0: resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} dependencies: @@ -1328,6 +1344,11 @@ packages: resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} dev: true + /font-awesome@4.7.0: + resolution: {integrity: sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg==} + engines: {node: '>=0.10.3'} + dev: false + /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} dependencies: @@ -1520,6 +1541,11 @@ packages: function-bind: 1.1.2 dev: true + /he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + dev: false + /ignore@5.2.4: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} @@ -2234,6 +2260,14 @@ packages: scheduler: 0.23.0 dev: false + /react-icons@4.12.0(react@18.2.0): + resolution: {integrity: sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==} + peerDependencies: + react: '*' + dependencies: + react: 18.2.0 + dev: false + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -2253,6 +2287,17 @@ packages: resize-observer-polyfill: 1.5.1 dev: false + /react-srv3@1.0.4(react@18.2.0): + resolution: {integrity: sha512-fKRX+F4d3gkzQ+VZxopovBO/aUV32s6Z+Rwt1SqmhFo7RSdFeXmhHuHx0jL/E99H7Y4mIMw4isplajGFfR2TnA==} + engines: {node: '>=10'} + peerDependencies: + react: ^16.0.0 + dependencies: + fast-xml-parser: 3.21.1 + he: 1.2.0 + react: 18.2.0 + dev: false + /react-toastify@9.1.3(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==} peerDependencies: @@ -2521,6 +2566,10 @@ packages: engines: {node: '>=8'} dev: true + /strnum@1.0.5: + resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} + dev: false + /styled-components@6.1.1(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-cpZZP5RrKRIClBW5Eby4JM1wElLVP4NQrJbJ0h10TidTyJf4SIIwa3zLXOoPb4gJi8MsJ8mjq5mu2IrEhZIAcQ==} engines: {node: '>= 16'} diff --git a/src/app/App.tsx b/src/app/App.tsx index 871e387..e813cae 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,8 +1,10 @@ -import React, { useEffect, useRef, useState } from 'react'; -import styled from 'styled-components'; -import KaraokePlayer from './components/KaraokePlayer'; -import { toast, ToastContainer } from 'react-toastify'; -import 'react-toastify/dist/ReactToastify.css'; +import React, { useEffect, useRef, useState } from "react"; +import styled from "styled-components"; +import KaraokePlayer from "./components/KaraokePlayer"; +import { toast, ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import { FaPlay, FaPause, FaVolumeUp, FaVolumeMute } from "react-icons/fa"; +import { CaptionsRenderer } from "react-srv3"; const Root = styled.div` position: absolute; @@ -24,7 +26,7 @@ const FileInputContainer = styled.div` padding: 10px; border-radius: 5px; background-color: #ffffff; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); `; const FileInput = styled.input` @@ -33,7 +35,8 @@ const FileInput = styled.input` border: 1px solid #ddd; cursor: pointer; display: none; - &:hover, &:focus { + &:hover, + &:focus { background-color: #eaeaea; outline: none; } @@ -44,21 +47,25 @@ const FileInputLabel = styled.label` border-radius: 5px; border: 1px solid #ddd; cursor: pointer; - &:hover, &:focus { + &:hover, + &:focus { background-color: #eaeaea; outline: none; } `; - function App() { - const [currentMillisecond, setCurrentMillisecond] = useState(0); - const [lrcContent, setLrcContent] = useState(''); - const [videoUrl, setVideoUrl] = useState(''); + const [lrcContent, setLrcContent] = useState(""); + const [videoUrl, setVideoUrl] = useState(""); + const [srv3Url, setSrv3Url] = useState(""); + const [isPlaying, setIsPlaying] = useState(false); + const [showVolume, setShowVolume] = useState(false); + const [scrubValue, setScrubValue] = useState(0); const [showFileInputs, setShowFileInputs] = useState(true); const videoRef = useRef<HTMLVideoElement>(null); - const [offset, setOffset] = useState('0'); + const [captionsText, setCaptionsText] = useState(""); + const [offset, setOffset] = useState("0"); const handleLrcFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; if (file) { @@ -71,60 +78,267 @@ function App() { toast.success("LRC file loaded successfully", { autoClose: 2000 }); } }; - - const handleVideoFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { + + const handleVideoFileChange = ( + event: React.ChangeEvent<HTMLInputElement> + ) => { const file = event.target.files?.[0]; if (file) { const url = URL.createObjectURL(file); setVideoUrl(url); - setShowFileInputs(true); toast.success("Video file loaded successfully", { autoClose: 2000 }); } }; + const handleSrvFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + setCaptionsText(e.target?.result as string); + }; + reader.readAsText(file); + toast.success("SRV file loaded successfully", { autoClose: 2000 }); + } + }; + + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.code === "Space") { + handlePlayPause(); + } + if (e.code === "ArrowRight") { + const video = videoRef.current; + if (!video) return; + video.currentTime += 5; + } + if (e.code === "ArrowLeft") { + const video = videoRef.current; + if (!video) return; + video.currentTime -= 5; + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }); + useEffect(() => { const video = videoRef.current; if (!video) return; - + <CaptionsRenderer srv3={captionsText} currentTime={currentMillisecond} />; const syncLrcWithVideo = () => { console.log(offset); - setCurrentMillisecond((video.currentTime * 1000) + parseInt(offset)); + setCurrentMillisecond(video.currentTime * 1000 + parseInt(offset)); + setScrubValue((video.currentTime / video.duration) * 100); }; - video.addEventListener('timeupdate', syncLrcWithVideo); + video.addEventListener("timeupdate", syncLrcWithVideo); return () => { - video.removeEventListener('timeupdate', syncLrcWithVideo); + video.removeEventListener("timeupdate", syncLrcWithVideo); }; }); + const handleVolumeToggle = () => { + setShowVolume(!showVolume); + }; + + const handlePlayPause = () => { + const video = videoRef.current; + if (!video) return; + + if (video.paused) { + video.play(); + setIsPlaying(true); + } else { + video.pause(); + setIsPlaying(false); + } + }; + + const handleScrub = (event: React.ChangeEvent<HTMLInputElement>) => { + const time = + (parseFloat(event.target.value) / 100) * videoRef.current!.duration; + videoRef.current!.currentTime = time; + setScrubValue(parseFloat(event.target.value)); + }; + + const handleVolumeChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const video = videoRef.current; + if (!video) return; + + video.volume = Number(event.target.value) / 100; + }; + return ( <Root> <ToastContainer /> - <div style={{ display: 'flex', width: '100%', height: '100vh' }}> + <div style={{ display: "flex", width: "100%", height: "100vh" }}> <KaraokePlayer lrc={lrcContent} currentMillisecond={currentMillisecond} /> - <div style={{ flex: 1, position: 'relative' }} onMouseEnter={() => setShowFileInputs(true)} onMouseLeave={() => setShowFileInputs(false)}> - {videoUrl ? <video ref={videoRef} src={videoUrl} controls style={{ width: '100%', height: '100%' }} /> :<div style={{ width: '100%', height: '100%', backgroundColor: '#ddd', display: 'flex', justifyContent: 'center', alignItems: 'center' }}> - <p style={{fontSize: '30px', textAlign: 'center', fontFamily:'Arial', fontWeight:'bold'}}> - Please select the video and lrc (lyrics) file <br/> - Hover over me for a menu</p> - </div> - } + <div + style={{ flex: 1, position: "relative" }} + onMouseEnter={() => setShowFileInputs(true)} + onMouseLeave={() => setShowFileInputs(false)} + > + {videoUrl ? ( + <> + <video + ref={videoRef} + src={videoUrl} + style={{ position: "absolute", width: "100%", height: "100%" }} + /> + <div style={{ width: '90%', height: '90%', margin: 'auto' }}> + <CaptionsRenderer + srv3={captionsText} + currentTime={currentMillisecond / 1000} + /> + </div> + <div + style={{ + position: "absolute", + bottom: 0, + left: 0, + width: "100%", + display: "flex", + alignItems: "center", + backgroundColor: "rgba(0, 0, 0, 0.5)", + height: "50px", + }} + > + <button + onClick={handlePlayPause} + style={{ + width: "50px", + height: "50px", + padding: "10px", + border: "none", + borderRadius: "5px", + backgroundColor: "white", + color: "black", + }} + > + {isPlaying ? <FaPause /> : <FaPlay />} + </button> + <input + type="range" + min="0" + max="100" + value={scrubValue} + style={{ flex: 1, height: "50px" }} + onInput={handleScrub} + /> + <div + style={{ + position: "relative", + width: "50px", + height: "50px", + }} + > + <button + onClick={handleVolumeToggle} + style={{ + width: "100%", + height: "100%", + padding: "10px", + border: "none", + borderRadius: "5px", + backgroundColor: "white", + color: "black", + }} + > + {!showVolume ? <FaVolumeMute /> : <FaVolumeUp />} + </button> + {showVolume && ( + <div + style={{ + position: "absolute", + bottom: "100%", + transform: + "translateX(-50%) translateY(-100%) rotate(-90deg)", + }} + > + <input + type="range" + min="0" + max="100" + defaultValue="100" + style={{ height: "40px", width: "100px" }} + onChange={handleVolumeChange} + /> + </div> + )} + </div> + </div> + </> + ) : ( + <div + style={{ + width: "100%", + height: "100%", + backgroundColor: "#ddd", + display: "flex", + justifyContent: "center", + alignItems: "center", + }} + > + <p + style={{ + fontSize: "30px", + textAlign: "center", + fontFamily: "Arial", + fontWeight: "bold", + }} + > + Please select the video and lrc (lyrics) file <br /> + Hover over me for a menu + </p> + </div> + )} {showFileInputs && ( - <FileInputContainer style={{ position: 'absolute', bottom: '20px', left: 0 }}> - <FileInputLabel htmlFor="lrcUpload" style={{ cursor: 'pointer' }}>LRC</FileInputLabel> - <FileInput id="lrcUpload" type="file" accept=".lrc" onChange={handleLrcFileChange} /> - <FileInputLabel htmlFor="videoUpload" style={{ cursor: 'pointer' }}>Video</FileInputLabel> - <FileInput id="videoUpload" type="file" accept="video/*" onChange={handleVideoFileChange} /> - <FileInputLabel htmlFor="srvUpload" style={{ cursor: 'pointer' }}>SRV</FileInputLabel> - <FileInput disabled type="file" accept=".srv" /> - <div style={{ display: 'flex', flexDirection: 'column', fontFamily: 'Arial' }}> + <FileInputContainer + style={{ position: "absolute", bottom: "30px", left: 0 }} + > + <FileInputLabel htmlFor="lrcUpload" style={{ cursor: "pointer" }}> + LRC + </FileInputLabel> + <FileInput + id="lrcUpload" + type="file" + accept=".lrc" + onChange={handleLrcFileChange} + /> + <FileInputLabel + htmlFor="videoUpload" + style={{ cursor: "pointer" }} + > + Video + </FileInputLabel> + <FileInput + id="videoUpload" + type="file" + accept="video/*" + onChange={handleVideoFileChange} + /> + <FileInputLabel htmlFor="srvUpload" style={{ cursor: "pointer" }}> + SRV + </FileInputLabel> + <FileInput id="srvUpload" type="file" accept=".srv3" onChange={handleSrvFileChange}/> + <div + style={{ + display: "flex", + flexDirection: "column", + fontFamily: "Arial", + }} + > <label>Offset (±ms) </label> <input type="number" - style={{ fontSize: '20px' }} + style={{ fontSize: "20px" }} id="numberInput" value={offset} onChange={(e) => setOffset(e.target.value)} diff --git a/src/app/components/VideoControls.tsx b/src/app/components/VideoControls.tsx new file mode 100644 index 0000000..bca8e97 --- /dev/null +++ b/src/app/components/VideoControls.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faPlay, + faPause, + faExpand, + faCompress, + faVolumeUp, + faVolumeMute +} from '@fortawesome/free-solid-svg-icons'; + +// Define a TypeScript interface for the component's props +interface VideoControlsProps { + videoRef: React.RefObject<HTMLVideoElement | null>; + isPlaying: boolean; + onPlayStateChange: (isPlaying: boolean) => void; + } +const VideoControls: React.FC<VideoControlsProps> = ({ videoRef, isPlaying, onPlayStateChange }) => { + const [isFullscreen, setIsFullscreen] = useState(false); + const [progress, setProgress] = useState(0); + const [volume, setVolume] = useState(1); + const [showVolumeSlider, setShowVolumeSlider] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + + const formatTime = (seconds: number) => { + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + if (hrs === 0) { + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }; + + useEffect(() => { + const updateProgress = () => { + const newProgress = (videoRef.current?.currentTime / videoRef.current?.duration) * 100; + setCurrentTime(videoRef.current?.currentTime || 0); + setProgress(newProgress || 0); + }; + + const currentVideoRef = videoRef.current; + currentVideoRef?.addEventListener('timeupdate', updateProgress); + return () => currentVideoRef?.removeEventListener('timeupdate', updateProgress); + }, [videoRef]); + + const handlePlayPause = () => { + if (videoRef.current?.paused) { + videoRef.current?.play(); + } else { + videoRef.current?.pause(); + } + onPlayStateChange(!videoRef.current?.paused); + }; + + const handleProgressChange = (e) => { + const newTime = (e.target.value / 100) * videoRef.current?.duration; + videoRef.current.currentTime = newTime; + setProgress(e.target.value); + }; + + const handleVolumeChange = (e) => { + const vol = e.target.value; + if (videoRef.current) { + videoRef.current.volume = vol; + } + setVolume(vol); + }; + + const toggleFullscreen = () => { + if (!document.fullscreenElement) { + videoRef.current?.requestFullscreen(); + setIsFullscreen(true); + } else { + document.exitFullscreen(); + setIsFullscreen(false); + } + }; + + const handleWheel = (e) => { + e.preventDefault(); + e.target.blur(); + }; + + return ( + <div className="video-controls bg-gray-700 bg-opacity-75 p-1 rounded space-x-4 flex items-center"> + <button + onClick={handlePlayPause} + className="p-2 rounded-full hover:bg-gray-600 transition" + > + <FontAwesomeIcon + icon={isPlaying ? faPause : faPlay} + className="text-white" + size="lg" + /> + </button> + <div className="text-white ml-3"> + {formatTime(currentTime)} /{" "} + {formatTime(videoRef.current?.duration || 0)} + </div> + <div className="relative flex-grow mx-4 flex items-center"> + <input + type="range" + value={progress} + onChange={handleProgressChange} + onWheel={handleWheel} + className="w-full cursor-pointer slider-thumb bg-red-500" + title="" + /> + </div> + + <button + onClick={() => setShowVolumeSlider(!showVolumeSlider)} + className="p-2 rounded-full hover:bg-gray-600 transition relative" + > + <FontAwesomeIcon + icon={volume > 0 ? faVolumeUp : faVolumeMute} + className="text-white" + size="lg" + /> + {showVolumeSlider && ( + <div className="absolute mb-7 bottom-8 left-1/2 transform -translate-x-1/2 w-20"> + <input + type="range" + value={volume} + onChange={handleVolumeChange} + min="0" + max="1" + step="0.01" + className="w-full cursor-pointer slider-thumb mb-4" + title="" + style={{ transform: "rotate(270deg)" }} + /> + </div> + )} + </button> + + <button + onClick={toggleFullscreen} + className="p-2 rounded-full hover:bg-gray-600 transition" + > + <FontAwesomeIcon + icon={isFullscreen ? faCompress : faExpand} + className="text-white" + size="lg" + /> + </button> + </div> + ); +}; + + +export default VideoControls; |
