From ca22fba860a2032bf0b80c225562d4b240fc80b2 Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Mon, 13 Apr 2026 17:47:26 -0700 Subject: scaffold index page --- src/app/about/page.tsx | 2 +- src/app/globals.css | 8 +- src/app/page.tsx | 1149 ++++++++++++----------------------------------- src/app/player/page.tsx | 922 +++++++++++++++++++++++++++++++++++++ 4 files changed, 1225 insertions(+), 856 deletions(-) create mode 100644 src/app/player/page.tsx (limited to 'src') diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 2e19d68..e75c6f8 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -222,7 +222,7 @@ const AboutPage: React.FC = () => { - Back to player + Back to player ); diff --git a/src/app/globals.css b/src/app/globals.css index 139597f..6cfbe02 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,2 +1,6 @@ - - +html, +body { + margin: 0; + padding: 0; + background-color: #f9f9f9; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 7b03272..5db9c41 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,942 +1,385 @@ "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"; - -// ─── Layout ────────────────────────────────────────────────────────────────── +import React, { useState } from "react"; +import styled from "styled-components"; +import Link from "next/link"; +import { FaPlay, FaMusic, FaBars, FaSearch, FaUserCircle } from "react-icons/fa"; +import { MdLibraryMusic } from "react-icons/md"; + const Root = styled.div` - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - background-color: #f5f5f5; - overflow: hidden; + min-height: 100vh; + background-color: #f9f9f9; + color: #1a1a1a; + font-family: "Roboto", "Segoe UI", Arial, sans-serif; `; -const PanesContainer = styled.div` +const Navbar = styled.nav` + position: sticky; + top: 0; + z-index: 100; display: flex; - flex: 1; - height: 100vh; - overflow: hidden; - user-select: none; + align-items: center; + justify-content: space-between; + height: 56px; + padding: 0 20px; + background-color: #ffffffee; + backdrop-filter: blur(12px); + border-bottom: 1px solid #e5e5e5; `; -// ─── Lyrics pane ───────────────────────────────────────────────────────────── - -const LyricsPane = styled.div<{ $width: number }>` - width: ${({ $width }) => $width}%; +const NavLeft = styled.div` display: flex; - flex-direction: column; - overflow: hidden; - background-color: #ffffff; + align-items: center; + gap: 14px; `; -// ─── Resize handle ─────────────────────────────────────────────────────────── - -const ResizeHandle = styled.div` - width: 5px; - flex-shrink: 0; - background-color: #ddd; - cursor: col-resize; - transition: background-color 0.15s ease; - position: relative; +const Logo = styled(Link)` + font-size: 17px; + font-weight: 800; + letter-spacing: 0.3px; + color: #1a1a1a; + text-decoration: none; + display: flex; + align-items: center; + gap: 7px; + user-select: none; +`; - &:hover, - &:active { - background-color: #aaa; - } +const LogoIcon = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + background-color: #1a1a1a; + color: #fff; + border-radius: 6px; + width: 30px; + height: 22px; + font-size: 10px; +`; - &::after { - content: ""; - position: absolute; - inset: 0 -4px; - } +const NavCenter = styled.div` + display: flex; + align-items: center; + flex: 0 1 560px; `; -const VideoPane = styled.div<{ $dragOver: boolean }>` +const SearchBox = styled.div` + display: flex; + align-items: center; flex: 1; - position: relative; - background-color: ${({ $dragOver }) => ($dragOver ? "#dbeeff" : "#ffffff")}; - transition: background-color 0.15s ease; + height: 38px; + border: 1px solid #d4d4d4; + border-radius: 10px; overflow: hidden; + background-color: #f0f0f0; + transition: border-color 0.2s; + &:focus-within { + border-color: #1a1a1a; + } `; -const VideoElement = styled.video` - position: absolute; - inset: 0; - width: 100%; +const SearchInput = styled.input` + flex: 1; height: 100%; + padding: 0 14px; + background: transparent; + border: none; + outline: none; + color: #1a1a1a; + font-size: 14px; + &::placeholder { + color: #909090; + } `; -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; +const SearchButton = styled.button` + width: 52px; + height: 100%; + background-color: #e8e8e8; border: none; - background-color: transparent; - color: #fff; + border-left: 1px solid #d4d4d4; + color: #606060; + font-size: 14px; 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); + background-color: #d4d4d4; + color: #1a1a1a; } `; -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` +const NavRight = 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; + gap: 6px; `; -const panelItemStyles = css` - display: inline-flex; +const Avatar = styled.div` + font-size: 28px; + color: #909090; + display: 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; - + padding: 4px; + border-radius: 50%; &:hover { - background-color: rgba(255, 255, 255, 0.16); - } - - &:focus { - outline: none; - border-color: rgba(255, 255, 255, 0.35); + color: #606060; } `; -const PanelLabel = styled.label` - ${panelItemStyles} -`; - -const PanelButton = styled.button` - ${panelItemStyles} -`; - -const HiddenFileInput = styled.input` - display: none; +const ChipsBar = styled.div` + display: flex; + align-items: center; + gap: 10px; + padding: 14px 24px; + overflow-x: auto; + background-color: #f9f9f9; + &::-webkit-scrollbar { + display: none; + } `; -const PanelFieldLabel = styled.span` - color: rgba(255, 255, 255, 0.45); - font-size: 11px; - font-family: Arial, sans-serif; +const Chip = styled.button<{ $active?: boolean }>` 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); + padding: 7px 16px; + border-radius: 10px; + border: 1px solid ${(p) => (p.$active ? "transparent" : "#d4d4d4")}; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + background-color: ${(p) => (p.$active ? "#1a1a1a" : "transparent")}; + color: ${(p) => (p.$active ? "#fff" : "#606060")}; + &:hover { + background-color: ${(p) => (p.$active ? "#333" : "#f0f0f0")}; + color: ${(p) => (p.$active ? "#fff" : "#1a1a1a")}; } +`; - /* Remove number input arrows in Firefox */ - -moz-appearance: textfield; +const GridContainer = styled.div` + padding: 8px 24px 24px; +`; - /* Remove number input arrows in Chrome/Safari */ - &::-webkit-inner-spin-button, - &::-webkit-outer-spin-button { - opacity: 0.4; - } +const CardGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; `; -const PanelRangeInput = styled.input` - width: 90px; - accent-color: rgba(255, 255, 255, 0.8); +const Card = styled.div` cursor: pointer; - vertical-align: middle; + border-radius: 14px; + transition: transform 0.15s, box-shadow 0.15s; + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + } `; -const PanelCheckboxLabel = styled.label` - display: inline-flex; +const ThumbnailWrapper = styled.div` + width: 100%; + aspect-ratio: 16 / 9; + background-color: #e4e4e4; + border-radius: 12px; + display: 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; + justify-content: center; + color: #c0c0c0; + font-size: 36px; + overflow: hidden; + position: relative; `; -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 PlayOverlay = styled.div` + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0); + border-radius: 12px; + transition: background 0.2s; + ${Card}:hover & { + background: rgba(0, 0, 0, 0.25); + } `; -// ─── Placeholder (no video loaded) ─────────────────────────────────────────── - -const PlaceholderWrapper = styled.div` - width: 100%; - height: 100%; +const PlayCircle = styled.div` + width: 48px; + height: 48px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.7); + color: #fff; 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; + font-size: 16px; + opacity: 0; + transform: scale(0.8); + transition: opacity 0.2s, transform 0.2s; + ${Card}:hover & { + opacity: 1; + transform: scale(1); + } `; -const PlaceholderHeading = styled.h1` - font-size: 28px; - font-weight: bold; - margin: 0 0 12px; +const CardMeta = styled.div` + display: flex; + gap: 12px; + margin-top: 12px; + padding: 12px 4px; `; -const PlaceholderBody = styled.p` - font-size: 18px; - line-height: 1.6; - margin: 0 0 20px; -`; -const CodeInputWrapper = styled.div` +const CardInfo = styled.div` display: flex; + padding: 4px; flex-direction: column; - gap: 8px; - width: 100%; - max-width: 420px; - font-family: Arial, sans-serif; + gap: 3px; + min-width: 0; +`; + +const CardTitle = styled.span` font-size: 14px; + font-weight: 600; + color: #1a1a1a; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; `; -const CodeInput = styled.input` - width: 100%; - font-size: 15px; - padding: 6px 8px; - border: 1px solid #ddd; - border-radius: 5px; - box-sizing: border-box; +const CardSub = styled.span` + font-size: 12px; + color: #909090; + line-height: 1.3; `; -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; +/* ── CTA Section ── */ - &:hover, - &:focus { - background-color: #eaeaea; - outline: none; - } +const CtaSection = styled.div` + padding: 32px 24px; + border-top: 1px solid #e5e5e5; + margin-top: 8px; `; -// ─── Misc ──────────────────────────────────────────────────────────────────── +const SectionHeading = styled.h2` + font-size: 17px; + font-weight: 700; + color: #1a1a1a; + margin: 0 0 14px; +`; -const StyledLink = styled.a` - font-family: Arial, sans-serif; +const OpenPlayerLink = styled(Link)` + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 22px; + border-radius: 10px; + background-color: #1a1a1a; + color: #fff; + font-size: 14px; + font-weight: 600; text-decoration: none; - color: #0066cc; - + transition: background-color 0.15s; &:hover { - text-decoration: underline; + background-color: #333; } `; -// ─── Component ─────────────────────────────────────────────────────────────── - -function KaraokePage() { - const [currentMillisecond, setCurrentMillisecond] = useState(0); - const [lrcContent, setLrcContent] = useState(""); - const [videoUrl, setVideoUrl] = useState(""); - const [supplementAudioUrl, setSupplementAudioUrl] = useState(""); - const [isPlaying, setIsPlaying] = useState(false); - const [scrubValue, setScrubValue] = useState(0); - const [showPanel, setShowPanel] = useState(true); - const videoRef = useRef(null); - const supplementAudioRef = useRef(null); - const [captionsText, setCaptionsText] = useState(""); - const [offset, setOffset] = useState(0); - const [dragOver, setDragOver] = useState(false); - const [statusText, setStatusText] = useState("No video selected"); - const [balance, setBalance] = useState(0); - const [animate, setAnimate] = useState(true); - const [lrcColor, setLrcColor] = useState("#C8BEBE"); - const [fontColor, setFontColor] = useState("#000000"); - const [supplementAudioOffset, setSupplementAudioOffset] = useState(0); - const [base64Input, setBase64Input] = useState(""); - - // Resizable panes - const [leftWidth, setLeftWidth] = useState(50); - const isResizing = useRef(false); - const containerRef = useRef(null); - - const searchParams = useSearchParams(); - - // ── Persist color preferences ───────────────────────────────────────────── - - 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]); - - // ── Resize logic ────────────────────────────────────────────────────────── - - const handleResizeMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - isResizing.current = true; - document.body.style.cursor = "col-resize"; - }, - [], - ); +const PlayerDescription = styled.p` + font-size: 13px; + color: #909090; + margin: 14px 0 0; + line-height: 1.6; + max-width: 480px; +`; - 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) => { - 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, - ) => { - 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) => { - 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, - ) => { - 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, - ) => { - 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"); - }; - - // ── Keyboard shortcuts ──────────────────────────────────────────────────── - - 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) => { - 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) => { - setDragOver(true); - event.preventDefault(); - }; - - const handleDragEnter = (event: React.DragEvent) => { - setDragOver(true); - event.preventDefault(); - }; - - const handleDragLeave = (event: React.DragEvent) => { - setDragOver(false); - event.preventDefault(); - }; - - const handleDrop = (event: React.DragEvent) => { - 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]); - - // ── Render ──────────────────────────────────────────────────────────────── +const CHIPS = ["All", "Music", "Karaoke", "Live", "J-Pop", "Anime", "Vocaloid", "Recently added"]; + +const STUB_ITEMS = [ + { title: "Mr.Raindrop - Amplified (Full Karaoke)", artist: "Amplified", uploaded: "2 days ago" }, +]; + +export default function HomePage() { + const [activeChip, setActiveChip] = useState("All"); return ( - - - - - - - - - - setShowPanel(true)} - onMouseLeave={() => setShowPanel(false)} - onDragOver={handleDragOver} - onDragEnter={handleDragEnter} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - > - {videoUrl ? ( - <> - - - + + + + + + + LRC-Karaoke-Player + + + + + + + + + + + + + + + + + + + + + {CHIPS.map((chip) => ( + setActiveChip(chip)} + > + {chip} + + ))} + + + + + {STUB_ITEMS.map((item) => ( + + + + + + + + + + + + {item.title} + {item.artist} + + + + ))} + + + + + Custom Player + + Open Player + + + Load your own video, audio, LRC lyrics + + ); } - -export default function Page() { - return ( - - - - ); -} 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(""); + const [videoUrl, setVideoUrl] = useState(""); + const [supplementAudioUrl, setSupplementAudioUrl] = useState(""); + const [isPlaying, setIsPlaying] = useState(false); + const [scrubValue, setScrubValue] = useState(0); + const [showPanel, setShowPanel] = useState(true); + const videoRef = useRef(null); + const supplementAudioRef = useRef(null); + const [captionsText, setCaptionsText] = useState(""); + const [offset, setOffset] = useState(0); + const [dragOver, setDragOver] = useState(false); + const [statusText, setStatusText] = useState("No video selected"); + const [balance, setBalance] = useState(0); + const [animate, setAnimate] = useState(true); + const [lrcColor, setLrcColor] = useState("#C8BEBE"); + const [fontColor, setFontColor] = useState("#000000"); + const [supplementAudioOffset, setSupplementAudioOffset] = useState(0); + const [base64Input, setBase64Input] = useState(""); + + // Resizable panes + const [leftWidth, setLeftWidth] = useState(50); + const isResizing = useRef(false); + const containerRef = useRef(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) => { + 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) => { + 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, + ) => { + 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) => { + 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, + ) => { + 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, + ) => { + 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) => { + 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) => { + setDragOver(true); + event.preventDefault(); + }; + + const handleDragEnter = (event: React.DragEvent) => { + setDragOver(true); + event.preventDefault(); + }; + + const handleDragLeave = (event: React.DragEvent) => { + setDragOver(false); + event.preventDefault(); + }; + + const handleDrop = (event: React.DragEvent) => { + 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 ( + + + + + + + + + + + setShowPanel(true)} + onMouseLeave={() => setShowPanel(false)} + onDragOver={handleDragOver} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + > + {videoUrl ? ( + <> + + + + + ); +} + +export default function Page() { + return ( + + + + ); +} -- cgit v1.2.3