2025-06-18 07:46:59 -04:00
|
|
|
import { CircularProgress } from "@mui/joy";
|
|
|
|
|
import React, {
|
|
|
|
|
forwardRef,
|
|
|
|
|
useImperativeHandle,
|
|
|
|
|
useEffect,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
} from "react";
|
|
|
|
|
import { default as $ } from "jquery";
|
|
|
|
|
import Alert from '@mui/joy/Alert';
|
|
|
|
|
import Box from '@mui/joy/Box';
|
|
|
|
|
import Button from "@mui/joy/Button";
|
|
|
|
|
import Checkbox from "@mui/joy/Checkbox";
|
2025-07-15 14:34:44 -04:00
|
|
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
2025-06-18 07:46:59 -04:00
|
|
|
import jQuery from "jquery";
|
|
|
|
|
import { AutoComplete } from 'primereact/autocomplete';
|
|
|
|
|
import { api as API_URL } from '../config';
|
|
|
|
|
|
|
|
|
|
window.$ = window.jQuery = jQuery;
|
|
|
|
|
const theme = document.documentElement.getAttribute("data-theme")
|
|
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
|
2025-06-18 07:46:59 -04:00
|
|
|
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).removeClass(removedClass)
|
|
|
|
|
$(box).addClass(`lyrics-card-${newTheme}`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export default function LyricSearch() {
|
2025-07-16 10:06:41 -04:00
|
|
|
const [showLyrics, setShowLyrics] = useState(false);
|
2025-06-18 07:46:59 -04:00
|
|
|
return (
|
|
|
|
|
<div className="lyric-search">
|
|
|
|
|
<h2 className="title">
|
|
|
|
|
<span>Lyric Search</span>
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="card-text my-4">
|
|
|
|
|
<label>Search:</label>
|
|
|
|
|
<LyricSearchInputField
|
|
|
|
|
id="lyric-search-input"
|
2025-07-16 10:06:41 -04:00
|
|
|
placeholder="Artist - Song"
|
|
|
|
|
setShowLyrics={setShowLyrics} />
|
2025-06-18 07:46:59 -04:00
|
|
|
<br />
|
|
|
|
|
Exclude:<br />
|
|
|
|
|
<div id="exclude-checkboxes">
|
|
|
|
|
<UICheckbox id="excl-Genius" label="Genius" />
|
|
|
|
|
<UICheckbox id="excl-LRCLib" label="LRCLib" />
|
|
|
|
|
<UICheckbox id="excl-Cache" label="Cache" />
|
|
|
|
|
</div>
|
|
|
|
|
<div id="spinner" className="hidden">
|
|
|
|
|
<CircularProgress
|
|
|
|
|
variant="plain"
|
|
|
|
|
color="primary"
|
|
|
|
|
size="md"/></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
2025-06-18 07:46:59 -04:00
|
|
|
const [value, setValue] = useState("");
|
|
|
|
|
const [suggestions, setSuggestions] = useState([]);
|
2025-07-16 10:06:41 -04:00
|
|
|
const [alertVisible, setAlertVisible] = useState(false);
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
const [excludedSources, setExcludedSources] = useState([]);
|
|
|
|
|
const [lyricsResult, setLyricsResult] = useState(null);
|
2025-06-18 07:46:59 -04:00
|
|
|
const autoCompleteRef = useRef(null);
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
// 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) => {
|
|
|
|
|
setExcludedSources((prev) =>
|
|
|
|
|
prev.includes(source)
|
|
|
|
|
? prev.filter((s) => s !== source)
|
|
|
|
|
: [...prev, source]
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Show scrollable dropdown panel with mouse wheel handling
|
2025-07-15 14:34:44 -04:00
|
|
|
const handlePanelShow = () => {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const panel = document.querySelector(".p-autocomplete-panel");
|
|
|
|
|
const items = panel?.querySelector(".p-autocomplete-items");
|
|
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
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 });
|
|
|
|
|
}
|
2025-07-15 14:34:44 -04:00
|
|
|
}, 0);
|
|
|
|
|
};
|
|
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
const handleSearch = async () => {
|
2025-06-18 07:46:59 -04:00
|
|
|
if (autoCompleteRef.current) {
|
|
|
|
|
autoCompleteRef.current.hide();
|
|
|
|
|
}
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
if (!value.includes(" - ")) {
|
|
|
|
|
setAlertVisible(true);
|
2025-06-18 07:46:59 -04:00
|
|
|
return;
|
|
|
|
|
}
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
const [artist, song] = value.split(" - ", 2).map((v) => v.trim());
|
|
|
|
|
if (!artist || !song) {
|
|
|
|
|
setAlertVisible(true);
|
2025-07-15 14:34:44 -04:00
|
|
|
return;
|
2025-06-18 07:46:59 -04:00
|
|
|
}
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
setAlertVisible(false);
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
setLyricsResult(null);
|
|
|
|
|
setShowLyrics(false);
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
const toastId = toast.info("Searching...", {
|
|
|
|
|
style: {
|
|
|
|
|
color: "#000",
|
|
|
|
|
backgroundColor: "rgba(217, 242, 255, 0.8)",
|
|
|
|
|
},
|
2025-07-15 14:34:44 -04:00
|
|
|
});
|
|
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
});
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
const data = await res.json();
|
|
|
|
|
if (!res.ok || !data.lyrics) {
|
|
|
|
|
throw new Error(data.errorText || "Unknown error.");
|
2025-06-18 07:46:59 -04:00
|
|
|
}
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
|
|
|
setLyricsResult({ artist: data.artist, song: data.song, lyrics: data.lyrics });
|
|
|
|
|
setShowLyrics(true);
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
toast.update(toastId, {
|
2025-06-18 07:46:59 -04:00
|
|
|
render: `🦄 Found! (Took ${duration}s)`,
|
2025-07-15 14:34:44 -04:00
|
|
|
type: "",
|
|
|
|
|
style: { backgroundColor: "rgba(46, 186, 106, 1)" },
|
2025-06-18 07:46:59 -04:00
|
|
|
autoClose: 2000,
|
2025-07-15 14:34:44 -04:00
|
|
|
hideProgressBar: true,
|
2025-06-18 07:46:59 -04:00
|
|
|
});
|
2025-07-16 10:06:41 -04:00
|
|
|
} catch (error) {
|
|
|
|
|
toast.update(toastId, {
|
|
|
|
|
render: `😕 ${error.message}`,
|
2025-07-01 11:38:20 -04:00
|
|
|
type: "",
|
2025-07-15 14:34:44 -04:00
|
|
|
style: { backgroundColor: "rgba(255, 0, 0, 0.5)" },
|
2025-06-18 07:46:59 -04:00
|
|
|
autoClose: 5000,
|
2025-07-16 10:06:41 -04:00
|
|
|
hideProgressBar: true,
|
2025-07-15 14:34:44 -04:00
|
|
|
});
|
2025-07-16 10:06:41 -04:00
|
|
|
} finally {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
}
|
2025-06-18 07:46:59 -04:00
|
|
|
};
|
2025-07-15 14:34:44 -04:00
|
|
|
|
|
|
|
|
const handleKeyDown = (e) => {
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
handleSearch();
|
|
|
|
|
}
|
2025-06-18 07:46:59 -04:00
|
|
|
};
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-06-18 07:46:59 -04:00
|
|
|
return (
|
|
|
|
|
<div>
|
2025-07-16 10:06:41 -04:00
|
|
|
{alertVisible && (
|
2025-07-15 14:34:44 -04:00
|
|
|
<Alert
|
|
|
|
|
color="danger"
|
|
|
|
|
variant="solid"
|
2025-07-16 10:06:41 -04:00
|
|
|
onClose={() => setAlertVisible(false)}
|
|
|
|
|
sx={{ mb: 2 }}
|
2025-07-15 14:34:44 -04:00
|
|
|
>
|
|
|
|
|
You must specify both an artist and song to search.
|
|
|
|
|
<br />
|
|
|
|
|
Format: Artist - Song
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
2025-07-16 10:06:41 -04:00
|
|
|
|
2025-07-15 14:34:44 -04:00
|
|
|
<AutoComplete
|
2025-07-16 10:06:41 -04:00
|
|
|
id={id}
|
2025-07-15 14:34:44 -04:00
|
|
|
ref={autoCompleteRef}
|
|
|
|
|
value={value}
|
|
|
|
|
suggestions={suggestions}
|
2025-07-16 10:06:41 -04:00
|
|
|
completeMethod={fetchSuggestions}
|
2025-07-15 14:34:44 -04:00
|
|
|
onChange={(e) => setValue(e.target.value)}
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
onShow={handlePanelShow}
|
2025-07-16 10:06:41 -04:00
|
|
|
placeholder={placeholder}
|
|
|
|
|
autoFocus
|
|
|
|
|
size={40}
|
|
|
|
|
/>
|
|
|
|
|
<Button onClick={handleSearch} className="btn">
|
|
|
|
|
Search
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
2025-06-18 07:46:59 -04:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-06-18 07:46:59 -04:00
|
|
|
export const UICheckbox = forwardRef(function UICheckbox(opts = {}, ref) {
|
|
|
|
|
const [checked, setChecked] = useState(false);
|
|
|
|
|
const [showAlert, setShowAlert] = useState(false);
|
|
|
|
|
let valid_exclusions = true;
|
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
|
|
|
setChecked: (val) => setChecked(val),
|
2025-06-18 11:41:03 -04:00
|
|
|
checked,
|
2025-06-18 07:46:59 -04:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const verifyExclusions = (e) => {
|
|
|
|
|
let exclude_error = false;
|
|
|
|
|
if (($("#exclude-checkboxes").find("input:checkbox").filter(":checked").length == 3)){
|
|
|
|
|
$("#exclude-checkboxes").find("input:checkbox").each(function () {
|
|
|
|
|
exclude_error = true;
|
|
|
|
|
this.click();
|
|
|
|
|
});
|
|
|
|
|
if (exclude_error) {
|
|
|
|
|
toast.error("All sources were excluded; exclusions have been reset.",
|
|
|
|
|
{ style: { backgroundColor: "rgba(255, 0, 0, 0.5)", color: 'inherit' } },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<Checkbox
|
|
|
|
|
id={opts.id}
|
|
|
|
|
key={opts.label}
|
|
|
|
|
checked={checked}
|
|
|
|
|
label={opts.label}
|
|
|
|
|
style={{ color: "inherit" }}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
setChecked(e.target.checked);
|
|
|
|
|
verifyExclusions();
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function LyricResultBox(opts={}) {
|
|
|
|
|
return (
|
2025-07-15 14:34:44 -04:00
|
|
|
<div>
|
2025-07-16 10:06:41 -04:00
|
|
|
<Box className={`lyrics-card lyrics-card-${theme}`} sx={{ p: 2 }}>
|
2025-07-15 14:34:44 -04:00
|
|
|
<div className='lyrics-content'></div>
|
|
|
|
|
{/* <ContentCopyIcon className='lyrics-card-copyButton' size='lg' /> */}
|
|
|
|
|
</Box>
|
|
|
|
|
</div>
|
2025-06-18 07:46:59 -04:00
|
|
|
)
|
|
|
|
|
}
|