Files
codey.lol/src/components/LyricSearch.jsx
2025-07-28 15:31:04 -04:00

312 lines
8.5 KiB
JavaScript

import { CircularProgress } from "@mui/joy";
import React, {
forwardRef,
useImperativeHandle,
useEffect,
useRef,
useState,
} from "react";
import Alert from '@mui/joy/Alert';
import Box from '@mui/joy/Box';
import Button from "@mui/joy/Button";
import Checkbox from "@mui/joy/Checkbox";
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { AutoComplete } from 'primereact/autocomplete';
import { API_URL } from '../config';
const theme = document.documentElement.getAttribute("data-theme")
document.addEventListener('set-theme', (e) => {
const box = document.querySelector("[class*='lyrics-card-']");
let removedClass = "lyrics-card-dark";
let newTheme = e.detail;
if (newTheme !== "light") {
removedClass = "lyrics-card-light";
}
box?.classList.remove(removedClass);
box?.classList.add(`lyrics-card-${newTheme}`);
});
export default function LyricSearch() {
const [showLyrics, setShowLyrics] = useState(false);
return (
<div className="lyric-search">
<h2 className="title">
<span>Lyric Search</span>
</h2>
<div className="card-text my-4">
<label for="lyric-search-input">Search:</label>
<LyricSearchInputField
id="lyric-search-input"
placeholder="Artist - Song"
setShowLyrics={setShowLyrics} />
<div id="spinner" className="hidden">
<CircularProgress
variant="plain"
color="primary"
size="md"/></div>
</div>
</div>
);
}
export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
const [value, setValue] = useState("");
const [suggestions, setSuggestions] = useState([]);
const [alertVisible, setAlertVisible] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [excludedSources, setExcludedSources] = useState([]);
const [lyricsResult, setLyricsResult] = useState(null);
const autoCompleteRef = useRef(null);
// Typeahead: fetch suggestions
const fetchSuggestions = 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 handleSearch = async () => {
if (autoCompleteRef.current) {
autoCompleteRef.current.hide();
}
if (!value.includes(" - ")) {
setAlertVisible(true);
return;
}
const [artist, song] = value.split(" - ", 2).map((v) => v.trim());
if (!artist || !song) {
setAlertVisible(true);
return;
}
setAlertVisible(false);
setIsLoading(true);
setLyricsResult(null);
setShowLyrics(false);
const toastId = toast.info("Searching...", {
style: {
color: "#000",
backgroundColor: "rgba(217, 242, 255, 0.8)",
},
});
const startTime = Date.now();
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,
}),
});
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);
setLyricsResult({ artist: data.artist, song: data.song, lyrics: data.lyrics });
setShowLyrics(true);
toast.update(toastId, {
render: `🦄 Found! (Took ${duration}s)`,
type: "",
style: { backgroundColor: "rgba(46, 186, 106, 1)" },
autoClose: 2000,
hideProgressBar: true,
});
} catch (error) {
toast.update(toastId, {
render: `😕 ${error.message}`,
type: "",
style: { backgroundColor: "rgba(255, 0, 0, 0.5)" },
autoClose: 5000,
hideProgressBar: true,
});
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSearch();
}
};
return (
<div>
{alertVisible && (
<Alert
color="danger"
variant="solid"
onClose={() => setAlertVisible(false)}
sx={{ mb: 2 }}
>
You must specify both an artist and song to search.
<br />
Format: Artist - Song
</Alert>
)}
<AutoComplete
id={id}
ref={autoCompleteRef}
value={value}
suggestions={suggestions}
completeMethod={fetchSuggestions}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onShow={handlePanelShow}
placeholder={placeholder}
autoFocus
size={40}
aria-controls="lyric-search-input"
/>
<Button onClick={handleSearch} className="btn">
Search
</Button>
<br />
Exclude:<br />
<div id="exclude-checkboxes">
<UICheckbox id="excl-Genius" label="Genius" onToggle={toggleExclusion} />
<UICheckbox id="excl-LRCLib" label="LRCLib" onToggle={toggleExclusion} />
<UICheckbox id="excl-Cache" label="Cache" onToggle={toggleExclusion} />
</div>
{isLoading && (
<div className="mt-3">
<CircularProgress variant="plain" color="primary" size="md" />
</div>
)}
{lyricsResult && (
<div className={`lyrics-card lyrics-card-${theme} mt-4 p-4 rounded-md shadow-md`}>
<div className="lyrics-content">
<div style={{ textAlign: "center", fontWeight: "bold", marginBottom: "1rem" }}>
{lyricsResult.artist} - {lyricsResult.song}
</div>
<div dangerouslySetInnerHTML={{ __html: 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-checkboxes input[type=checkbox]");
const checkedCount = [...checkboxes].filter(cb => cb.checked).length;
if (checkedCount === 3) {
checkboxes.forEach(cb => cb.click());
toast.error("All sources were excluded; exclusions have been reset.", {
style: { backgroundColor: "rgba(255, 0, 0, 0.5)", color: "inherit" }
});
}
};
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 (
<div>
<Checkbox
id={props.id}
key={props.label}
checked={checked}
label={props.label}
style={{ color: "inherit" }}
onChange={handleChange}
/>
</div>
);
});
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>
)
}