Files
codey.lol/src/components/LyricSearch.jsx
2025-12-19 07:46:46 -05:00

655 lines
21 KiB
JavaScript

import { CircularProgress } from "@mui/joy";
import React, {
forwardRef,
useImperativeHandle,
useEffect,
useRef,
useState,
useCallback,
} from "react";
import { toast } from 'react-toastify';
import DOMPurify from 'isomorphic-dompurify';
import Box from '@mui/joy/Box';
import Button from "@mui/joy/Button";
import IconButton from "@mui/joy/IconButton";
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import LinkIcon from '@mui/icons-material/Link';
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
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';
// Sanitize HTML from external sources to prevent XSS
const sanitizeHtml = (html) => {
if (!html) return '';
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['br', 'p', 'b', 'i', 'em', 'strong', 'span', 'div'],
ALLOWED_ATTR: ['class'],
});
};
export default function LyricSearch() {
const [showLyrics, setShowLyrics] = useState(false);
return (
<div className="lyric-search w-full">
{/* Hero section */}
<div className="mt-8 mb-12 text-center flex flex-col items-center">
<div className="relative w-32 h-32 flex items-center justify-center mb-4">
<div
className="absolute inset-0 rounded-full"
style={{
background: 'radial-gradient(circle at 50% 50%, rgba(168,85,247,0.25) 0%, rgba(168,85,247,0.15) 30%, rgba(236,72,153,0.08) 60%, transparent 80%)',
}}
></div>
<span className="relative text-6xl" style={{ marginTop: '-4px' }}>🎤</span>
</div>
<h1 className="text-4xl font-bold mb-3 text-neutral-900 dark:text-white tracking-tight">
Lyric Search
</h1>
<p className="text-neutral-600 dark:text-neutral-400 text-base max-w-sm leading-relaxed">
Search millions of songs instantly.<br />
<span className="text-neutral-400 dark:text-neutral-500 text-sm">Powered by Genius, LRCLib & more</span>
</p>
</div>
<LyricSearchInputField
id="lyric-search-input"
placeholder="Artist - Song"
setShowLyrics={setShowLyrics}
/>
</div>
);
}
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 [youtubeVideo, setYoutubeVideo] = useState(null); // { video_id, title, channel, duration }
const [youtubeLoading, setYoutubeLoading] = useState(false);
const [highlightedVerse, setHighlightedVerse] = useState(null);
const [isLyricsVisible, setIsLyricsVisible] = useState(false);
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);
}, []);
// Fetch YouTube video for the song
const fetchYouTubeVideo = useCallback(async (artist, song) => {
setYoutubeLoading(true);
try {
const res = await fetch(`${API_URL}/yt/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ t: `${artist} - ${song}` }),
});
if (!res.ok) {
setYoutubeVideo(null);
setYoutubeLoading(false);
return;
}
const data = await res.json();
if (data?.video_id) {
setYoutubeVideo({
video_id: data.video_id,
title: data.extras?.title || `${artist} - ${song}`,
channel: data.extras?.channel || '',
duration: data.extras?.duration || '',
thumbnail: data.extras?.thumbnails?.[0] || null,
});
} else {
setYoutubeVideo(null);
}
} catch (err) {
console.error('YouTube search failed:', err);
setYoutubeVideo(null);
}
setYoutubeLoading(false);
}, []);
// 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]);
// Robustly hide autocomplete panel
const hideAutocompletePanel = useCallback(() => {
// Method 1: Use PrimeReact API
if (autoCompleteRef.current?.hide) {
try {
autoCompleteRef.current.hide();
} catch (e) {
// Ignore
}
}
// Method 2: Clear suggestions to ensure panel hides
setSuggestions([]);
// Method 3: Force hide via DOM after a tick (fallback)
setTimeout(() => {
const panel = document.querySelector('.p-autocomplete-panel');
if (panel) {
panel.style.display = 'none';
panel.style.opacity = '0';
panel.style.visibility = 'hidden';
panel.style.pointerEvents = 'none';
}
}, 10);
}, []);
const handleSearch = async (searchValue = value) => {
hideAutocompletePanel();
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";
if (!toast.isActive("lyrics-validation-error-toast")) {
toast.error(message, { toastId: "lyrics-validation-error-toast" });
}
return;
}
const { artist, song } = evaluation;
setIsLoading(true);
setLyricsResult(null);
setYoutubeVideo(null); // Reset YouTube video
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, src: data.src });
setHighlightedVerse(null);
setIsLyricsVisible(false);
// Trigger fade-in animation
requestAnimationFrame(() => {
requestAnimationFrame(() => setIsLyricsVisible(true));
});
setShowLyrics(true);
// Update URL hash with search parameters
const hash = `#${encodeURIComponent(data.artist)}/${encodeURIComponent(data.song)}`;
window.history.pushState(null, '', hash);
// Search for YouTube video (don't block on this)
fetchYouTubeVideo(data.artist, data.song);
dismissSearchToast();
toast.success(`Found! (Took ${duration}s)`, {
autoClose: 2500,
toastId: "lyrics-success-toast",
});
} catch (error) {
dismissSearchToast();
toast.error(error.message, {
icon: () => "😕",
autoClose: 5000,
toastId: "lyrics-error-toast",
});
} finally {
setIsLoading(false);
hideAutocompletePanel();
autoCompleteInputRef.current?.blur();
searchButtonRef.current?.blur();
searchToastRef.current = null;
}
};
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
hideAutocompletePanel();
handleSearch();
}
};
const handleCopyLyrics = useCallback(async () => {
if (!lyricsResult?.lyrics) {
return;
}
const temp = document.createElement("div");
temp.innerHTML = lyricsResult.lyrics
.replace(/<br\s*\/?>(\s*<br\s*\/?>)?/gi, (_match, doubleBreak) => {
return doubleBreak ? "\n\n" : "\n";
})
.replace(/<p\b[^>]*>/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 (
<div className="w-full">
<div className="lyric-search-input-wrapper">
<AutoComplete
id={id}
ref={autoCompleteRef}
value={value}
suggestions={suggestions}
completeMethod={fetchSuggestions}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onShow={handlePanelShow}
placeholder={placeholder}
autoFocus
style={{ 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"
/>
<span
className={`input-status-icon input-status-icon-${inputStatus}`}
title={statusTitle}
aria-hidden="true"
style={{ opacity: inputStatus === "hint" ? 0.4 : 1 }}
>
<StatusIcon fontSize="small" htmlColor={statusColors[inputStatus]} />
</span>
<span className="sr-only" aria-live="polite">
{statusTitle}
</span>
</div>
<div className="flex flex-wrap items-center justify-center gap-4 mt-5 mb-8">
<Button
onClick={() => handleSearch()}
className="search-btn"
ref={searchButtonRef}
>
Search
</Button>
<div className="h-6 w-px bg-neutral-300 dark:bg-neutral-700 hidden sm:block" aria-hidden="true"></div>
<div className="exclude-sources">
<span className="exclude-label hidden sm:inline">Exclude:</span>
<UICheckbox id="excl-Genius" label="Genius" value="Genius" onToggle={toggleExclusion} />
<UICheckbox id="excl-LRCLib-Cache" label="LRCLib" value="LRCLib-Cache" onToggle={toggleExclusion} />
<UICheckbox id="excl-Cache" label="Cache" value="Cache" onToggle={toggleExclusion} />
</div>
</div>
{isLoading && (
<div className="mt-6 flex justify-center">
<CircularProgress variant="plain" color="primary" size="md" />
</div>
)}
{lyricsResult && (
<div className={`lyrics-card lyrics-card-${theme} mt-6 p-5 rounded-xl shadow-lg lyrics-card-animate ${isLyricsVisible ? 'lyrics-card-visible' : ''}`}>
<div className="lyrics-toolbar">
<div className="lyrics-title" title={`${lyricsResult.artist} - ${lyricsResult.song}`}>
{lyricsResult.artist} - {lyricsResult.song}
</div>
<div className="lyrics-actions">
{lyricsResult.src && (() => {
let sourceLabel, sourceClass;
const src = lyricsResult.src.toLowerCase();
if (src.includes('redis cache')) {
sourceLabel = 'Cache';
sourceClass = 'source-pill--cache';
} else if (src.includes('genius')) {
sourceLabel = 'Genius';
sourceClass = 'source-pill--genius';
} else if (src.includes('lrclib')) {
sourceLabel = 'LRCLib';
sourceClass = 'source-pill--lrclib';
} else {
sourceLabel = lyricsResult.src.split(' ')[0];
sourceClass = 'source-pill--other';
}
return <span className={`source-pill ${sourceClass}`}>{sourceLabel}</span>;
})()}
<div className="text-size-buttons" aria-label="Lyric text size">
<button
type="button"
className={`text-size-btn text-size-large ${textSize === "large" ? "active" : ""}`}
onClick={() => setTextSize("large")}
title="Larger text"
>
Aa
</button>
<button
type="button"
className={`text-size-btn ${textSize === "normal" ? "active" : ""}`}
onClick={() => setTextSize("normal")}
title="Default text"
>
Aa
</button>
</div>
<IconButton
size="sm"
variant="plain"
color="neutral"
aria-label="Copy lyrics"
title="Copy lyrics"
className="lyrics-action-button"
onClick={handleCopyLyrics}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
<IconButton
size="sm"
variant="plain"
color="neutral"
aria-label="Copy lyric link"
title="Copy shareable link"
className="lyrics-action-button"
onClick={handleCopyLink}
>
<LinkIcon fontSize="small" />
</IconButton>
{youtubeLoading && (
<IconButton
size="sm"
variant="plain"
color="neutral"
aria-label="Loading YouTube video..."
title="Finding video..."
className="lyrics-action-button"
disabled
sx={{ opacity: 0.3 }}
>
<PlayCircleOutlineIcon fontSize="small" />
</IconButton>
)}
{!youtubeLoading && youtubeVideo && (
<IconButton
size="sm"
variant="plain"
color="neutral"
aria-label="Watch on YouTube"
title={`Watch: ${youtubeVideo.title}`}
className="lyrics-action-button"
component="a"
href={`https://www.youtube.com/watch?v=${youtubeVideo.video_id}`}
target="_blank"
rel="noopener noreferrer"
>
<PlayCircleOutlineIcon fontSize="small" />
</IconButton>
)}
</div>
</div>
<div className={`lyrics-content ${textSize === "large" ? "lyrics-content-large" : ""}`}>
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(lyricsResult.lyrics) }} />
</div>
</div>
)}
</div>
);
}
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-chip");
const checkedCount = [...checkboxes].filter(cb => cb.dataset.checked === 'true').length;
if (checkedCount === 3) {
// Reset all by triggering clicks
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 handleClick = () => {
const newChecked = !checked;
setChecked(newChecked);
if (props.onToggle) {
const source = props.value || props.label;
props.onToggle(source);
}
setTimeout(verifyExclusions, 0);
};
return (
<button
type="button"
id={props.id}
className={`exclude-chip ${checked ? 'exclude-chip--active' : ''}`}
data-checked={checked}
onClick={handleClick}
aria-pressed={checked}
>
{props.label}
</button>
);
});
export function LyricResultBox(opts = {}) {
return (
<div>
<Box className={`lyrics-card lyrics-card-${theme}`} sx={{ p: 2 }}>
<div className='lyrics-content'></div>
{/* <ContentCopyIcon className='lyrics-card-copyButton' size='lg' /> */}
</Box>
</div>
)
}