aboutsummaryrefslogtreecommitdiffstats
path: root/src/app/player
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-04-13 17:47:26 -0700
committerPinapelz <yukais@pinapelz.com>2026-04-13 17:47:26 -0700
commitca22fba860a2032bf0b80c225562d4b240fc80b2 (patch)
treedf46ae6642442e89e974d9fd8cd4cf0950aa6251 /src/app/player
parent38e55c528806301a46c4f6bdae8116f14d2af614 (diff)
scaffold index page
Diffstat (limited to 'src/app/player')
-rw-r--r--src/app/player/page.tsx922
1 files changed, 922 insertions, 0 deletions
diff --git a/src/app/player/page.tsx b/src/app/player/page.tsx
new file mode 100644
index 0000000..1f1d80c
--- /dev/null
+++ b/src/app/player/page.tsx
@@ -0,0 +1,922 @@
+"use client";
+import React, {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ Suspense,
+} from "react";
+import styled, { css } from "styled-components";
+import LRCPlayer from "../components/LRCPlayer";
+import { toast, ToastContainer } from "react-toastify";
+import "react-toastify/dist/ReactToastify.css";
+import {
+ FaPlay,
+ FaPause,
+ FaFileAlt,
+ FaVideo,
+ FaClosedCaptioning,
+ FaHeadphones,
+ FaSyncAlt,
+} from "react-icons/fa";
+import { CaptionsRenderer } from "react-srv3";
+import { useSearchParams } from "next/navigation";
+
+const Root = styled.div`
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ background-color: #f5f5f5;
+ overflow: hidden;
+`;
+
+const PanesContainer = styled.div`
+ display: flex;
+ flex: 1;
+ height: 100vh;
+ overflow: hidden;
+ user-select: none;
+`;
+
+const LyricsPane = styled.div<{ $width: number }>`
+ width: ${({ $width }) => $width}%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background-color: #ffffff;
+`;
+
+const ResizeHandle = styled.div`
+ width: 5px;
+ flex-shrink: 0;
+ background-color: #ddd;
+ cursor: col-resize;
+ transition: background-color 0.15s ease;
+ position: relative;
+
+ &:hover,
+ &:active {
+ background-color: #aaa;
+ }
+
+ &::after {
+ content: "";
+ position: absolute;
+ inset: 0 -4px;
+ }
+`;
+
+const VideoPane = styled.div<{ $dragOver: boolean }>`
+ flex: 1;
+ position: relative;
+ background-color: ${({ $dragOver }) => ($dragOver ? "#dbeeff" : "#ffffff")};
+ transition: background-color 0.15s ease;
+ overflow: hidden;
+`;
+
+const VideoElement = styled.video`
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+`;
+
+const CaptionsOverlay = styled.div`
+ position: absolute;
+ inset: 0;
+ width: 90%;
+ height: 90%;
+ margin: auto;
+ cursor: pointer;
+`;
+
+const ControlBar = styled.div`
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 50px;
+ display: flex;
+ align-items: center;
+ background-color: rgba(0, 0, 0, 0.6);
+ z-index: 10;
+`;
+
+const PlayButton = styled.button`
+ flex-shrink: 0;
+ width: 50px;
+ height: 50px;
+ padding: 0;
+ border: none;
+ background-color: transparent;
+ color: #fff;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 15px;
+ transition: background-color 0.15s ease;
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.12);
+ }
+`;
+
+const ScrubBar = styled.input`
+ flex: 1;
+ height: 4px;
+ margin: 0 12px 0 4px;
+ cursor: pointer;
+ accent-color: #fff;
+`;
+
+const ControlPanel = styled.div<{ $visible: boolean }>`
+ position: absolute;
+ bottom: 50px;
+ left: 0;
+ right: 0;
+ background: rgba(14, 14, 14, 0.88);
+ backdrop-filter: blur(8px);
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ padding: 7px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ z-index: 9;
+ transform: translateY(${({ $visible }) => ($visible ? "0" : "6px")});
+ opacity: ${({ $visible }) => ($visible ? 1 : 0)};
+ pointer-events: ${({ $visible }) => ($visible ? "auto" : "none")};
+ transition:
+ transform 0.18s ease,
+ opacity 0.18s ease;
+`;
+
+const PanelRow = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ flex-wrap: wrap;
+`;
+
+const PanelDivider = styled.div`
+ width: 1px;
+ height: 20px;
+ background-color: rgba(255, 255, 255, 0.15);
+ flex-shrink: 0;
+ margin: 0 2px;
+`;
+
+const panelItemStyles = css`
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 10px;
+ border-radius: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ background-color: rgba(255, 255, 255, 0.07);
+ color: rgba(255, 255, 255, 0.85);
+ font-size: 12px;
+ font-family: Arial, sans-serif;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background-color 0.15s ease;
+ line-height: 1.4;
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.16);
+ }
+
+ &:focus {
+ outline: none;
+ border-color: rgba(255, 255, 255, 0.35);
+ }
+`;
+
+const PanelLabel = styled.label`
+ ${panelItemStyles}
+`;
+
+const PanelButton = styled.button`
+ ${panelItemStyles}
+`;
+
+const HiddenFileInput = styled.input`
+ display: none;
+`;
+
+const PanelFieldLabel = styled.span`
+ color: rgba(255, 255, 255, 0.45);
+ font-size: 11px;
+ font-family: Arial, sans-serif;
+ white-space: nowrap;
+`;
+
+const PanelNumberInput = styled.input`
+ width: 68px;
+ padding: 3px 6px;
+ border-radius: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ background-color: rgba(255, 255, 255, 0.08);
+ color: #fff;
+ font-size: 12px;
+ font-family: Arial, sans-serif;
+
+ &:focus {
+ outline: none;
+ border-color: rgba(255, 255, 255, 0.4);
+ }
+
+ /* Remove number input arrows in Firefox */
+ -moz-appearance: textfield;
+
+ /* Remove number input arrows in Chrome/Safari */
+ &::-webkit-inner-spin-button,
+ &::-webkit-outer-spin-button {
+ opacity: 0.4;
+ }
+`;
+
+const PanelRangeInput = styled.input`
+ width: 90px;
+ accent-color: rgba(255, 255, 255, 0.8);
+ cursor: pointer;
+ vertical-align: middle;
+`;
+
+const PanelCheckboxLabel = styled.label`
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ color: rgba(255, 255, 255, 0.8);
+ font-size: 12px;
+ font-family: Arial, sans-serif;
+ cursor: pointer;
+ user-select: none;
+ white-space: nowrap;
+`;
+
+const ColorSwatch = styled.input`
+ width: 24px;
+ height: 24px;
+ padding: 1px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 3px;
+ cursor: pointer;
+ background: none;
+ vertical-align: middle;
+`;
+
+const PlaceholderWrapper = styled.div`
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ box-sizing: border-box;
+ text-align: center;
+ font-family: Arial, sans-serif;
+`;
+
+const PlaceholderHeading = styled.h1`
+ font-size: 28px;
+ font-weight: bold;
+ margin: 0 0 12px;
+`;
+
+const PlaceholderBody = styled.p`
+ font-size: 18px;
+ line-height: 1.6;
+ margin: 0 0 20px;
+`;
+
+const CodeInputWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ width: 100%;
+ max-width: 420px;
+ font-family: Arial, sans-serif;
+ font-size: 14px;
+`;
+
+const CodeInput = styled.input`
+ width: 100%;
+ font-size: 15px;
+ padding: 6px 8px;
+ border: 1px solid #ddd;
+ border-radius: 5px;
+ box-sizing: border-box;
+`;
+
+const LoadButton = styled.button`
+ padding: 7px 14px;
+ border-radius: 5px;
+ border: 1px solid #ddd;
+ background-color: #fff;
+ font-family: Arial, sans-serif;
+ font-size: 13px;
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+
+ &:hover,
+ &:focus {
+ background-color: #eaeaea;
+ outline: none;
+ }
+`;
+
+const StyledLink = styled.a`
+ font-family: Arial, sans-serif;
+ text-decoration: none;
+ color: #0066cc;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
+
+function KaraokePage() {
+ const [currentMillisecond, setCurrentMillisecond] = useState(0);
+ const [lrcContent, setLrcContent] = useState<string>("");
+ const [videoUrl, setVideoUrl] = useState<string>("");
+ const [supplementAudioUrl, setSupplementAudioUrl] = useState<string>("");
+ const [isPlaying, setIsPlaying] = useState<boolean>(false);
+ const [scrubValue, setScrubValue] = useState<number>(0);
+ const [showPanel, setShowPanel] = useState<boolean>(true);
+ const videoRef = useRef<HTMLVideoElement>(null);
+ const supplementAudioRef = useRef<HTMLAudioElement>(null);
+ const [captionsText, setCaptionsText] = useState<string>("");
+ const [offset, setOffset] = useState<number>(0);
+ const [dragOver, setDragOver] = useState<boolean>(false);
+ const [statusText, setStatusText] = useState<string>("No video selected");
+ const [balance, setBalance] = useState<number>(0);
+ const [animate, setAnimate] = useState<boolean>(true);
+ const [lrcColor, setLrcColor] = useState<string>("#C8BEBE");
+ const [fontColor, setFontColor] = useState<string>("#000000");
+ const [supplementAudioOffset, setSupplementAudioOffset] = useState<number>(0);
+ const [base64Input, setBase64Input] = useState<string>("");
+
+ // Resizable panes
+ const [leftWidth, setLeftWidth] = useState<number>(50);
+ const isResizing = useRef(false);
+ const containerRef = useRef<HTMLDivElement>(null);
+
+ const searchParams = useSearchParams();
+
+ useEffect(() => {
+ const savedLrcColor = localStorage.getItem("lrcColor");
+ const savedFontColor = localStorage.getItem("fontColor");
+ if (savedLrcColor) setLrcColor(savedLrcColor);
+ if (savedFontColor) setFontColor(savedFontColor);
+ }, []);
+
+ useEffect(() => {
+ localStorage.setItem("lrcColor", lrcColor);
+ }, [lrcColor]);
+
+ useEffect(() => {
+ localStorage.setItem("fontColor", fontColor);
+ }, [fontColor]);
+
+ const handleResizeMouseDown = useCallback(
+ (e: React.MouseEvent<HTMLDivElement>) => {
+ e.preventDefault();
+ isResizing.current = true;
+ document.body.style.cursor = "col-resize";
+ },
+ [],
+ );
+
+ useEffect(() => {
+ const onMouseMove = (e: MouseEvent) => {
+ if (!isResizing.current || !containerRef.current) return;
+ const rect = containerRef.current.getBoundingClientRect();
+ const newWidth = ((e.clientX - rect.left) / rect.width) * 100;
+ setLeftWidth(Math.min(Math.max(newWidth, 15), 85));
+ };
+
+ const onMouseUp = () => {
+ if (!isResizing.current) return;
+ isResizing.current = false;
+ document.body.style.cursor = "";
+ };
+
+ window.addEventListener("mousemove", onMouseMove);
+ window.addEventListener("mouseup", onMouseUp);
+ return () => {
+ window.removeEventListener("mousemove", onMouseMove);
+ window.removeEventListener("mouseup", onMouseUp);
+ };
+ }, []);
+
+ const handleLrcFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ setLrcContent(e.target?.result as string);
+ if (videoUrl) setShowPanel(false);
+ };
+ reader.readAsText(file);
+ toast.success("LRC file loaded successfully", { autoClose: 2000 });
+ }
+ };
+
+ const handleVideoFileChange = (
+ event: React.ChangeEvent<HTMLInputElement>,
+ ) => {
+ const file = event.target.files?.[0];
+ if (file) {
+ const url = URL.createObjectURL(file);
+ setVideoUrl(url);
+ setCurrentMillisecond(0);
+ setScrubValue(0);
+ setIsPlaying(false);
+ 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 });
+ }
+ };
+
+ const handleSupplementAudioFileChange = (
+ event: React.ChangeEvent<HTMLInputElement>,
+ ) => {
+ const file = event.target.files?.[0];
+ const video = videoRef.current;
+ if (file) {
+ const url = URL.createObjectURL(file);
+ setSupplementAudioUrl(url);
+ setCurrentMillisecond(0);
+ setScrubValue(0);
+ setIsPlaying(false);
+ if (video) video.pause();
+ toast.success("Supplemental audio file loaded successfully", {
+ autoClose: 2000,
+ });
+ }
+ };
+
+ const handleOnClickDemoButton = (
+ event: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
+ ) => {
+ event.preventDefault();
+ setOffset(-1550);
+ fetch(
+ "https://utfs.io/f/e2e18ea7-9841-437b-9ca3-5723355bd41a-rlck46.lrc",
+ ).then((response) => {
+ response.text().then((text) => setLrcContent(text));
+ });
+ setVideoUrl(
+ "https://utfs.io/f/84f5dfa6-821d-407f-a16d-a685b09c11d9-7xx2h4.webm",
+ );
+ toast.success("Loading Demo: Mr.Raindrop - Amplified");
+ toast.success("Applied offset of -1550ms");
+ };
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.code === "Space") handlePlayPause();
+ if (e.code === "ArrowRight") {
+ if (document.activeElement?.tagName === "INPUT") return;
+ const video = videoRef.current;
+ if (video) video.currentTime += 5;
+ }
+ if (e.code === "ArrowLeft") {
+ if (document.activeElement?.tagName === "INPUT") return;
+ const video = videoRef.current;
+ if (video) video.currentTime -= 5;
+ }
+ };
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ });
+
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+ const sync = () => {
+ setCurrentMillisecond(video.currentTime * 1000 + offset);
+ setScrubValue((video.currentTime / video.duration) * 100);
+ };
+ video.addEventListener("timeupdate", sync);
+ return () => video.removeEventListener("timeupdate", sync);
+ });
+
+ useEffect(() => {
+ const video = videoRef.current;
+ const audio = supplementAudioRef.current;
+ if (!video || !audio) return;
+ if (balance < 0) {
+ video.volume = 1 + balance;
+ audio.volume = 1;
+ } else {
+ video.volume = 1;
+ audio.volume = 1 - balance;
+ }
+ }, [balance]);
+
+ useEffect(() => {
+ const video = videoRef.current;
+ const audio = supplementAudioRef.current;
+ if (!video || !audio) return;
+ audio.currentTime = video.currentTime + supplementAudioOffset / 1000;
+ }, [supplementAudioOffset]);
+
+ useEffect(() => {
+ if (videoUrl && lrcContent) setStatusText("Ready to play!");
+ else if (videoUrl) setStatusText("No lyrics file selected");
+ else if (lrcContent) setStatusText("No video file selected");
+ else setStatusText("No video or lyrics file selected");
+ }, [videoUrl, lrcContent]);
+
+ const handlePlayPause = () => {
+ const video = videoRef.current;
+ if (!video) return;
+ if (video.paused) {
+ video.play();
+ if (supplementAudioUrl) supplementAudioRef.current?.play();
+ setIsPlaying(true);
+ } else {
+ video.pause();
+ if (supplementAudioUrl) supplementAudioRef.current?.pause();
+ setIsPlaying(false);
+ }
+ };
+
+ const handleScrub = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const time =
+ (parseFloat(event.target.value) / 100) * videoRef.current!.duration;
+ videoRef.current!.currentTime = time;
+ if (supplementAudioRef.current) {
+ supplementAudioRef.current.currentTime =
+ time + supplementAudioOffset / 1000;
+ }
+ setScrubValue(parseFloat(event.target.value));
+ };
+
+ const handleVideoEnded = () => {
+ setIsPlaying(false);
+ supplementAudioRef.current?.pause();
+ };
+
+ const syncSupplementAudioWithVideo = () => {
+ const video = videoRef.current;
+ const audio = supplementAudioRef.current;
+ if (!video || !audio) return;
+ audio.currentTime = video.currentTime + supplementAudioOffset / 1000;
+ };
+
+ const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
+ setDragOver(true);
+ event.preventDefault();
+ };
+
+ const handleDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
+ setDragOver(true);
+ event.preventDefault();
+ };
+
+ const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
+ setDragOver(false);
+ event.preventDefault();
+ };
+
+ const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
+ event.preventDefault();
+ setDragOver(false);
+ const file = event.dataTransfer.files?.[0];
+ if (!file) return;
+
+ if (file.name.endsWith(".lrc")) {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ setLrcContent(e.target?.result as string);
+ if (videoUrl) setShowPanel(false);
+ };
+ reader.readAsText(file);
+ toast.success("LRC file loaded successfully", { autoClose: 2000 });
+ } else if (file.name.endsWith(".srv3")) {
+ const reader = new FileReader();
+ reader.onload = (e) => setCaptionsText(e.target?.result as string);
+ reader.readAsText(file);
+ toast.success("SRV file loaded successfully", { autoClose: 2000 });
+ } else if (file.type.startsWith("video") || file.type.startsWith("audio")) {
+ const url = URL.createObjectURL(file);
+ setVideoUrl(url);
+ setCurrentMillisecond(0);
+ setScrubValue(0);
+ setIsPlaying(false);
+ toast.success("Video/Audio file loaded successfully", {
+ autoClose: 2000,
+ });
+ } else {
+ toast.error("Unsupported file type", { autoClose: 2000 });
+ }
+ };
+
+ function processData(data: any) {
+ if (data.lrc) {
+ fetch(data.lrc)
+ .then((r) => r.text())
+ .then((text) => {
+ setLrcContent(text);
+ if (videoUrl) setShowPanel(false);
+ toast.success("LRC file loaded successfully", { autoClose: 2000 });
+ })
+ .catch(() =>
+ toast.error("Failed to load LRC file", { autoClose: 2000 }),
+ );
+ }
+ if (data.srv3) {
+ fetch(data.srv3)
+ .then((r) => r.text())
+ .then((text) => {
+ setCaptionsText(text);
+ toast.success("SRV file loaded successfully", { autoClose: 2000 });
+ })
+ .catch(() =>
+ toast.error("Failed to load SRV3 file", { autoClose: 2000 }),
+ );
+ }
+ if (data.file1) {
+ setVideoUrl(data.file1);
+ setCurrentMillisecond(0);
+ setScrubValue(0);
+ setIsPlaying(false);
+ toast.success("Video file loaded successfully", { autoClose: 2000 });
+ }
+ if (data.file2) {
+ setSupplementAudioUrl(data.file2);
+ setCurrentMillisecond(0);
+ setScrubValue(0);
+ setIsPlaying(false);
+ toast.success("Supplemental audio file loaded successfully", {
+ autoClose: 2000,
+ });
+ }
+ if (data.offset) setOffset(Number(data.offset));
+ if (data.offset2) setSupplementAudioOffset(Number(data.offset2));
+ }
+
+ const handleKaraokeb64Code = () => {
+ try {
+ const data = JSON.parse(atob(base64Input));
+ processData(data);
+ toast.success("Data loaded successfully", { autoClose: 2000 });
+ } catch {
+ toast.error("Invalid base64 or JSON data", { autoClose: 2000 });
+ }
+ };
+
+ useEffect(() => {
+ const dataParam = searchParams.get("code");
+ if (dataParam) {
+ try {
+ const data = JSON.parse(atob(dataParam));
+ processData(data);
+ toast.success("Data loaded from query parameter", { autoClose: 2000 });
+ } catch {
+ toast.error("Invalid data in query parameter", { autoClose: 2000 });
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchParams]);
+
+ return (
+ <Root>
+ <ToastContainer />
+
+ <PanesContainer ref={containerRef}>
+ <LyricsPane $width={leftWidth}>
+ <LRCPlayer
+ lrc={lrcContent}
+ currentMillisecond={currentMillisecond}
+ animate={animate}
+ lrcColor={lrcColor}
+ fontColor={fontColor}
+ />
+ </LyricsPane>
+
+ <ResizeHandle onMouseDown={handleResizeMouseDown} />
+
+ <VideoPane
+ $dragOver={dragOver}
+ onMouseEnter={() => setShowPanel(true)}
+ onMouseLeave={() => setShowPanel(false)}
+ onDragOver={handleDragOver}
+ onDragEnter={handleDragEnter}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ >
+ {videoUrl ? (
+ <>
+ <VideoElement
+ ref={videoRef}
+ src={videoUrl}
+ onEnded={handleVideoEnded}
+ />
+ <audio
+ ref={supplementAudioRef}
+ src={supplementAudioUrl || undefined}
+ style={{ display: "none" }}
+ />
+ <CaptionsOverlay onClick={handlePlayPause}>
+ <CaptionsRenderer
+ srv3={captionsText}
+ currentTime={currentMillisecond / 1000}
+ />
+ </CaptionsOverlay>
+
+ <ControlBar>
+ <PlayButton
+ onClick={handlePlayPause}
+ aria-label={isPlaying ? "Pause" : "Play"}
+ >
+ {isPlaying ? <FaPause /> : <FaPlay />}
+ </PlayButton>
+ <ScrubBar
+ type="range"
+ min="0"
+ max="100"
+ value={scrubValue}
+ onChange={handleScrub}
+ />
+ </ControlBar>
+ </>
+ ) : (
+ <PlaceholderWrapper>
+ <PlaceholderHeading>{statusText}</PlaceholderHeading>
+ <PlaceholderBody>
+ Please select the video and lrc (lyrics) file
+ <br />
+ (Drag and Drop them here, or use the menus below!)
+ <br />
+ <br />
+ Chrome is recommended!
+ <br />
+ <StyledLink href="/about">About</StyledLink>
+ {" ยท "}
+ <StyledLink href="" onClick={handleOnClickDemoButton}>
+ Demo
+ </StyledLink>
+ </PlaceholderBody>
+
+ <CodeInputWrapper>
+ <label htmlFor="base64Input">
+ or enter a MoekyunKaraoke code:
+ </label>
+ <CodeInput
+ id="base64Input"
+ type="text"
+ value={base64Input}
+ onChange={(e) => setBase64Input(e.target.value)}
+ />
+ <LoadButton onClick={handleKaraokeb64Code}>
+ Load Data
+ </LoadButton>
+ </CodeInputWrapper>
+ </PlaceholderWrapper>
+ )}
+
+ <ControlPanel $visible={showPanel}>
+ <PanelRow>
+ <PanelLabel htmlFor="lrcUpload">
+ <FaFileAlt /> LRC
+ </PanelLabel>
+ <HiddenFileInput
+ id="lrcUpload"
+ type="file"
+ accept=".lrc"
+ onChange={handleLrcFileChange}
+ />
+
+ <PanelLabel htmlFor="videoUpload">
+ <FaVideo /> Media
+ </PanelLabel>
+ <HiddenFileInput
+ id="videoUpload"
+ type="file"
+ accept="video/*,audio/*"
+ onChange={handleVideoFileChange}
+ />
+
+ <PanelLabel htmlFor="srvUpload">
+ <FaClosedCaptioning /> SRV
+ </PanelLabel>
+ <HiddenFileInput
+ id="srvUpload"
+ type="file"
+ accept=".srv3"
+ onChange={handleSrvFileChange}
+ />
+
+ <PanelLabel htmlFor="supplementAudioUpload">
+ <FaHeadphones /> Audio #2
+ </PanelLabel>
+ <HiddenFileInput
+ id="supplementAudioUpload"
+ type="file"
+ accept="audio/*"
+ onChange={handleSupplementAudioFileChange}
+ />
+
+ <PanelDivider />
+
+ <PanelButton onClick={syncSupplementAudioWithVideo}>
+ <FaSyncAlt /> Sync Audio
+ </PanelButton>
+ </PanelRow>
+
+ <PanelRow>
+ <PanelFieldLabel>Balance</PanelFieldLabel>
+ <PanelRangeInput
+ type="range"
+ min="-1"
+ max="1"
+ step="0.01"
+ value={balance}
+ onChange={(e) => setBalance(Number(e.target.value))}
+ />
+
+ <PanelDivider />
+
+ <PanelFieldLabel>Offset</PanelFieldLabel>
+ <PanelNumberInput
+ type="number"
+ value={offset}
+ onChange={(e) => setOffset(Number(e.target.value))}
+ step="25"
+ />
+
+ <PanelDivider />
+
+ <PanelFieldLabel>Audio 2 Offset</PanelFieldLabel>
+ <PanelNumberInput
+ type="number"
+ value={supplementAudioOffset}
+ onChange={(e) =>
+ setSupplementAudioOffset(Number(e.target.value))
+ }
+ step="25"
+ />
+
+ <PanelDivider />
+
+ <PanelCheckboxLabel>
+ <input
+ type="checkbox"
+ checked={animate}
+ onChange={(e) => setAnimate(e.target.checked)}
+ />
+ Animate
+ </PanelCheckboxLabel>
+
+ <PanelDivider />
+
+ <PanelFieldLabel>Colors</PanelFieldLabel>
+ <ColorSwatch
+ type="color"
+ value={lrcColor}
+ onChange={(e) => setLrcColor(e.target.value)}
+ title="Highlight colour"
+ />
+ <ColorSwatch
+ type="color"
+ value={fontColor}
+ onChange={(e) => setFontColor(e.target.value)}
+ title="Font colour"
+ />
+ <PanelButton
+ onClick={() => {
+ setLrcColor("#C8BEBE");
+ setFontColor("#000000");
+ }}
+ >
+ Reset
+ </PanelButton>
+ </PanelRow>
+ </ControlPanel>
+ </VideoPane>
+ </PanesContainer>
+ </Root>
+ );
+}
+
+export default function Page() {
+ return (
+ <Suspense>
+ <KaraokePage />
+ </Suspense>
+ );
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage