import { CircularProgress } from "@mui/joy"; import React, { forwardRef, useImperativeHandle, useEffect, useRef, useState, useCallback, } from "react"; import { toast } from 'react-toastify'; import Box from '@mui/joy/Box'; import Button from "@mui/joy/Button"; import IconButton from "@mui/joy/IconButton"; import Checkbox from "@mui/joy/Checkbox"; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import LinkIcon from '@mui/icons-material/Link'; import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded'; import { AutoComplete } from 'primereact/autocomplete/autocomplete.esm.js'; import { API_URL } from '../config'; export default function LyricSearch() { const [showLyrics, setShowLyrics] = useState(false); return (

Lyric Search

); } export function LyricSearchInputField({ id, placeholder, setShowLyrics }) { const [value, setValue] = useState(""); const [suggestions, setSuggestions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [excludedSources, setExcludedSources] = useState([]); const [lyricsResult, setLyricsResult] = useState(null); const [textSize, setTextSize] = useState("normal"); const [inputStatus, setInputStatus] = useState("hint"); const searchToastRef = useRef(null); const autoCompleteRef = useRef(null); const autoCompleteInputRef = useRef(null); const searchButtonRef = useRef(null); const [theme, setTheme] = useState(document.documentElement.getAttribute("data-theme") || "light"); const statusLabels = { hint: "Format: Artist - Song", error: "Artist and song required", ready: "Format looks good", }; const statusIcons = { hint: RemoveRoundedIcon, error: CloseRoundedIcon, ready: CheckCircleRoundedIcon, }; const statusColors = { hint: "#9ca3af", error: "#ef4444", ready: "#22c55e", }; // Handle URL hash changes and initial load useEffect(() => { const handleHashChange = () => { const hash = window.location.hash.slice(1); // Remove the # symbol if (hash) { try { const [artist, song] = decodeURIComponent(hash).split('/'); if (artist && song) { setValue(`${artist} - ${song}`); handleSearch(`${artist} - ${song}`); } } catch (e) { console.error('Failed to parse URL hash:', e); } } }; window.addEventListener('hashchange', handleHashChange); handleHashChange(); // Handle initial load return () => window.removeEventListener('hashchange', handleHashChange); }, []); useEffect(() => { const handler = (e) => { const newTheme = e.detail; setTheme(newTheme); }; document.addEventListener('set-theme', handler); return () => { document.removeEventListener('set-theme', handler); }; }, []); // Typeahead: fetch suggestions const fetchSuggestions = useCallback(async (event) => { const query = event.query; const res = await fetch(`${API_URL}/typeahead/lyrics`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query }), }); const json = await res.json(); setSuggestions(json); }, []); // Toggle exclusion state for checkboxes const toggleExclusion = (source) => { const lower = source.toLowerCase(); setExcludedSources((prev) => prev.includes(lower) ? prev.filter((s) => s !== lower) : [...prev, lower] ); }; // Show scrollable dropdown panel with mouse wheel handling const handlePanelShow = () => { setTimeout(() => { const panel = document.querySelector(".p-autocomplete-panel"); const items = panel?.querySelector(".p-autocomplete-items"); if (items) { items.style.maxHeight = "200px"; items.style.overflowY = "auto"; items.style.overscrollBehavior = "contain"; const wheelHandler = (e) => { const delta = e.deltaY; const atTop = items.scrollTop === 0; const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight; if ((delta < 0 && atTop) || (delta > 0 && atBottom)) { e.preventDefault(); } else { e.stopPropagation(); } }; items.removeEventListener("wheel", wheelHandler); items.addEventListener("wheel", wheelHandler, { passive: false }); } }, 0); }; const evaluateSearchValue = useCallback((searchValue, shouldUpdate = true) => { const trimmed = searchValue?.trim() || ""; let status = "hint"; if (!trimmed) { if (shouldUpdate) setInputStatus(status); return { status, valid: false }; } if (!trimmed.includes(" - ")) { status = "error"; if (shouldUpdate) setInputStatus(status); return { status, valid: false }; } const [artist, song] = trimmed.split(" - ", 2).map((v) => v.trim()); if (!artist || !song) { status = "error"; if (shouldUpdate) setInputStatus(status); return { status, valid: false }; } status = "ready"; if (shouldUpdate) setInputStatus(status); return { status, valid: true, artist, song }; }, []); useEffect(() => { evaluateSearchValue(value); }, [value, evaluateSearchValue]); const handleSearch = async (searchValue = value) => { if (autoCompleteRef.current) { autoCompleteRef.current.hide(); } autoCompleteInputRef.current?.blur(); // blur early so the suggestion panel closes immediately const evaluation = evaluateSearchValue(searchValue); if (!evaluation?.valid) { const message = statusLabels[evaluation?.status || inputStatus] || "Please use Artist - Song"; toast.error(message); return; } const { artist, song } = evaluation; setIsLoading(true); setLyricsResult(null); setShowLyrics(false); const toastId = "lyrics-searching-toast"; toast.dismiss(toastId); searchToastRef.current = toast.info("Searching...", { toastId, }); const startTime = Date.now(); const dismissSearchToast = () => { if (searchToastRef.current) { toast.dismiss(searchToastRef.current); searchToastRef.current = null; } }; try { const res = await fetch(`${API_URL}/lyric/search`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ a: artist, s: song, excluded_sources: excludedSources, src: "Web", extra: true, }), }); if (res.status === 429) { throw new Error("Too many requests. Please wait a moment and try again."); } const data = await res.json(); if (!res.ok || !data.lyrics) { throw new Error(data.errorText || "Unknown error."); } const duration = ((Date.now() - startTime) / 1000).toFixed(1); setTextSize("normal"); setLyricsResult({ artist: data.artist, song: data.song, lyrics: data.lyrics }); setShowLyrics(true); // Update URL hash with search parameters const hash = `#${encodeURIComponent(data.artist)}/${encodeURIComponent(data.song)}`; window.history.pushState(null, '', hash); dismissSearchToast(); toast.success(`Found! (Took ${duration}s)`, { autoClose: 2500, toastId: `lyrics-success-${Date.now()}`, }); } catch (error) { dismissSearchToast(); toast.error(error.message, { icon: () => "😕", autoClose: 5000, }); } finally { setIsLoading(false); autoCompleteInputRef.current?.blur(); searchButtonRef.current?.blur(); searchToastRef.current = null; } }; const handleKeyDown = (e) => { if (e.key === "Enter") { e.preventDefault(); handleSearch(); } }; const handleCopyLyrics = useCallback(async () => { if (!lyricsResult?.lyrics) { return; } const temp = document.createElement("div"); temp.innerHTML = lyricsResult.lyrics .replace(/(\s*)?/gi, (_match, doubleBreak) => { return doubleBreak ? "\n\n" : "\n"; }) .replace(/]*>/gi, "") .replace(/<\/p>/gi, "\n\n"); const plainText = temp.textContent || temp.innerText || ""; try { await navigator.clipboard.writeText(plainText); toast.success("Lyrics copied to clipboard", { autoClose: 1500 }); } catch (err) { toast.error("Unable to copy lyrics"); console.error("Copy failed", err); } }, [lyricsResult]); const handleCopyLink = useCallback(async () => { if (!lyricsResult) { return; } try { const url = new URL(window.location.href); const hash = `#${encodeURIComponent(lyricsResult.artist)}/${encodeURIComponent(lyricsResult.song)}`; url.hash = hash; await navigator.clipboard.writeText(url.toString()); toast.success("Lyric link copied", { autoClose: 1500 }); } catch (err) { toast.error("Unable to copy link"); console.error("Link copy failed", err); } }, [lyricsResult]); const statusTitle = statusLabels[inputStatus]; const StatusIcon = statusIcons[inputStatus] || RemoveRoundedIcon; useEffect(() => { const inputEl = autoCompleteInputRef.current; if (!inputEl) return; if (statusTitle) { inputEl.setAttribute("title", statusTitle); } else { inputEl.removeAttribute("title"); } }, [statusTitle]); return (
setValue(e.target.value)} onKeyDown={handleKeyDown} onShow={handlePanelShow} placeholder={placeholder} autoFocus style={{ width: '100%', maxWidth: '900px' }} inputStyle={{ width: '100%' }} className={`lyric-search-input ${inputStatus === "error" ? "has-error" : ""} ${inputStatus === "ready" ? "has-ready" : ""}`} aria-invalid={inputStatus === "error"} aria-label={`Lyric search input. ${statusTitle}`} inputRef={(el) => { autoCompleteInputRef.current = el; }} aria-controls="lyric-search-input" /> {statusTitle}
Exclude:
{isLoading && (
)} {lyricsResult && (
{lyricsResult.artist} - {lyricsResult.song}
)}
); } export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) { const [checked, setChecked] = useState(false); useImperativeHandle(ref, () => ({ setChecked: (val) => setChecked(val), checked, })); const verifyExclusions = () => { const checkboxes = document.querySelectorAll("#exclude-checkboxes input[type=checkbox]"); const checkedCount = [...checkboxes].filter(cb => cb.checked).length; if (checkedCount === 3) { checkboxes.forEach(cb => cb.click()); if (!toast.isActive("lyrics-exclusion-reset-toast")) { toast.error("All sources were excluded; exclusions have been reset.", { toastId: "lyrics-exclusion-reset-toast" } ); } } }; const handleChange = (e) => { const newChecked = e.target.checked; setChecked(newChecked); if (props.onToggle) { const source = props.label; // Use label as source identifier props.onToggle(source); } verifyExclusions(); }; return (
); }); export function LyricResultBox(opts = {}) { return (
{/* */}
) }