feat: complete rework with postgresql local-first, system audio capture, live search, and real harmonic suggestions
This commit is contained in:
1 parent
f79843d5b6
commit
f854944dc1
1088 files changed
+2119
-198536
No files matched your search
+302
-102
@@ -1,12 +1,20 @@
|
||||
let isListening = true;
|
||||
let isListening = false;
|
||||
let currentTrack = null;
|
||||
let currentSource = 'system'; // 'system', 'mic', 'device'
|
||||
let currentStream = null;
|
||||
let mediaRecorder = null;
|
||||
let audioChunks = [];
|
||||
let audioContext = null;
|
||||
let analyser = null;
|
||||
let animFrameId = null;
|
||||
let isProcessingAudio = false;
|
||||
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const isLight = html.getAttribute('data-theme') === 'light';
|
||||
const next = isLight ? 'dark' : 'light';
|
||||
html.setAttribute('data-theme', next);
|
||||
document.getElementById('theme-btn').textContent = isLight ? 'Light' : 'Dark';
|
||||
document.getElementById('theme-btn').textContent = isLight ? 'Dark' : 'Light';
|
||||
if (currentTrack && currentTrack.key) {
|
||||
window.updateWheel(currentTrack.key);
|
||||
} else {
|
||||
@@ -14,135 +22,313 @@ function toggleTheme() {
|
||||
}
|
||||
}
|
||||
|
||||
let mediaRecorder;
|
||||
let audioChunks = [];
|
||||
let stream = null;
|
||||
// Audio Source Management
|
||||
async function changeAudioSource(source) {
|
||||
currentSource = source;
|
||||
const deviceSelect = document.getElementById('audio-device-select');
|
||||
|
||||
if (source === 'device') {
|
||||
deviceSelect.style.display = 'inline-block';
|
||||
await populateAudioDevices();
|
||||
} else {
|
||||
deviceSelect.style.display = 'none';
|
||||
}
|
||||
|
||||
// If already listening, restart stream with new source
|
||||
if (isListening) {
|
||||
stopAudioStream();
|
||||
await initAudioStream();
|
||||
}
|
||||
}
|
||||
|
||||
async function populateAudioDevices() {
|
||||
const deviceSelect = document.getElementById('audio-device-select');
|
||||
deviceSelect.innerHTML = '<option value="">Gerät wählen...</option>';
|
||||
|
||||
async function startRecordingLoop() {
|
||||
if (!isListening) return;
|
||||
|
||||
try {
|
||||
if (!stream) {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
// Request permission once to get labels
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())).catch(() => {});
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const audioInputs = devices.filter(d => d.kind === 'audioinput');
|
||||
|
||||
audioInputs.forEach((d, i) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.deviceId;
|
||||
opt.textContent = d.label || `Audioeingang ${i + 1}`;
|
||||
deviceSelect.appendChild(opt);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[Audio] Could not enumerate devices:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectSpecificDevice(deviceId) {
|
||||
if (!deviceId) return;
|
||||
currentSource = 'device';
|
||||
if (isListening) {
|
||||
stopAudioStream();
|
||||
await initAudioStream(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
// Stream Initialization
|
||||
async function initAudioStream(specificDeviceId = null) {
|
||||
try {
|
||||
stopAudioStream();
|
||||
|
||||
if (currentSource === 'system') {
|
||||
updateStatus('Wähle Tab/Bildschirm mit Audio...', 'waiting');
|
||||
// System audio capture (YouTube, VLC, DJ software, browser tabs)
|
||||
const displayStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
systemAudio: 'include'
|
||||
}
|
||||
});
|
||||
|
||||
const audioTracks = displayStream.getAudioTracks();
|
||||
if (audioTracks.length === 0) {
|
||||
alert('Kein Audiotrack geteilt! Bitte setze im Teilen-Dialog das Häkchen bei "Audio teilen" (Systemaudio teilen).');
|
||||
displayStream.getTracks().forEach(t => t.stop());
|
||||
toggleListening(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stop video track to save CPU and battery
|
||||
displayStream.getVideoTracks().forEach(t => t.stop());
|
||||
currentStream = new MediaStream(audioTracks);
|
||||
|
||||
} else if (currentSource === 'device' && specificDeviceId) {
|
||||
currentStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { deviceId: { exact: specificDeviceId } }
|
||||
});
|
||||
} else {
|
||||
// Microphone
|
||||
currentStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
|
||||
mediaRecorder.ondataavailable = event => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.push(event.data);
|
||||
|
||||
// When the user stops sharing via the browser bar
|
||||
currentStream.getAudioTracks()[0].onended = () => {
|
||||
console.log('[Audio] Stream ended by user.');
|
||||
toggleListening(false);
|
||||
};
|
||||
|
||||
// Setup Visualizer
|
||||
setupVisualizer(currentStream);
|
||||
return true;
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Audio] Stream init failed:', err);
|
||||
if (err.name !== 'NotAllowedError') {
|
||||
alert('Audio-Zugriff fehlgeschlagen: ' + err.message);
|
||||
}
|
||||
toggleListening(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopAudioStream() {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
try { mediaRecorder.stop(); } catch (e) {}
|
||||
}
|
||||
if (currentStream) {
|
||||
currentStream.getTracks().forEach(t => t.stop());
|
||||
currentStream = null;
|
||||
}
|
||||
if (animFrameId) {
|
||||
cancelAnimationFrame(animFrameId);
|
||||
animFrameId = null;
|
||||
}
|
||||
const meter = document.getElementById('audio-meter-fill');
|
||||
if (meter) meter.style.width = '0%';
|
||||
}
|
||||
|
||||
// Live Visualizer (Level Meter)
|
||||
function setupVisualizer(stream) {
|
||||
try {
|
||||
if (!audioContext || audioContext.state === 'closed') {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume();
|
||||
}
|
||||
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
|
||||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||
const meter = document.getElementById('audio-meter-fill');
|
||||
|
||||
function updateMeter() {
|
||||
if (!isListening) {
|
||||
meter.style.width = '0%';
|
||||
return;
|
||||
}
|
||||
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
sum += dataArray[i];
|
||||
}
|
||||
const avg = sum / dataArray.length;
|
||||
const pct = Math.min(100, Math.round((avg / 128) * 100));
|
||||
meter.style.width = `${pct}%`;
|
||||
|
||||
animFrameId = requestAnimationFrame(updateMeter);
|
||||
}
|
||||
|
||||
updateMeter();
|
||||
} catch (err) {
|
||||
console.warn('[Visualizer] Error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Listening Loop & Recognition
|
||||
async function toggleListening(forceState = null) {
|
||||
isListening = forceState !== null ? forceState : !isListening;
|
||||
|
||||
const statusEl = document.getElementById('listening-status');
|
||||
const icon = statusEl.querySelector('i');
|
||||
const text = document.getElementById('listening-text');
|
||||
|
||||
if (isListening) {
|
||||
statusEl.classList.remove('paused');
|
||||
statusEl.classList.add('active');
|
||||
icon.className = 'fa-solid fa-microphone-lines';
|
||||
|
||||
const sourceLabel = currentSource === 'system' ? 'PC-Audio' : 'Mikrofon';
|
||||
text.textContent = `Höre ${sourceLabel}...`;
|
||||
|
||||
const success = await initAudioStream();
|
||||
if (success) {
|
||||
startRecordingChunk();
|
||||
}
|
||||
} else {
|
||||
statusEl.classList.remove('active');
|
||||
statusEl.classList.add('paused');
|
||||
icon.className = 'fa-solid fa-pause';
|
||||
text.textContent = 'Pausiert';
|
||||
stopAudioStream();
|
||||
}
|
||||
}
|
||||
|
||||
function startRecordingChunk() {
|
||||
if (!isListening || !currentStream) return;
|
||||
|
||||
audioChunks = [];
|
||||
try {
|
||||
mediaRecorder = new MediaRecorder(currentStream, {
|
||||
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : undefined
|
||||
});
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data && e.data.size > 0) {
|
||||
audioChunks.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
if (!isListening) return;
|
||||
const audioBlob = new Blob(audioChunks);
|
||||
|
||||
const blob = new Blob(audioChunks, { type: 'audio/webm' });
|
||||
audioChunks = [];
|
||||
await processAudio(audioBlob);
|
||||
|
||||
// Start next chunk immediately if still listening
|
||||
|
||||
// Check if sound was loud enough before querying recognition
|
||||
if (!isProcessingAudio && blob.size > 1000) {
|
||||
await processAudio(blob);
|
||||
}
|
||||
|
||||
if (isListening) {
|
||||
startRecordingLoop();
|
||||
setTimeout(startRecordingChunk, 1500); // 1.5s pause between checks
|
||||
}
|
||||
};
|
||||
|
||||
// Record for 12 seconds
|
||||
|
||||
// Record for 7 seconds (optimal for ACRCloud / AudD)
|
||||
mediaRecorder.start();
|
||||
setTimeout(() => {
|
||||
if (mediaRecorder.state === 'recording') {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
}, 12000);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Microphone access denied or error:", err);
|
||||
alert("Microphone access is required to detect songs.");
|
||||
if (isListening) toggleListening();
|
||||
}, 7000);
|
||||
|
||||
} catch (e) {
|
||||
console.error('[Recorder] Error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function processAudio(blob) {
|
||||
isProcessingAudio = true;
|
||||
updateStatus('Analysiere Track...', 'analyzing');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('audio', blob, 'recording.webm');
|
||||
|
||||
formData.append('audio', blob, 'sample.webm');
|
||||
|
||||
try {
|
||||
const recRes = await fetch('/api/recognize', { method: 'POST', body: formData });
|
||||
const recData = await recRes.json();
|
||||
|
||||
if (recData.status === 'success') {
|
||||
const { title, artist, matchScore } = recData.data;
|
||||
|
||||
// Don't update if it's the exact same track to avoid flicker
|
||||
if (currentTrack && currentTrack.title === title && currentTrack.artist === artist) {
|
||||
const res = await fetch('/api/recognize', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json.status === 'success' && json.data) {
|
||||
const { title, artist, matchScore, key, bpm, cover_url, isCorrected } = json.data;
|
||||
|
||||
// Avoid refetching if same track is still playing
|
||||
if (currentTrack && currentTrack.title.toLowerCase() === title.toLowerCase() && currentTrack.artist.toLowerCase() === artist.toLowerCase()) {
|
||||
updateStatus(`Laufend: ${title}`, 'active');
|
||||
isProcessingAudio = false;
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('track-title').textContent = title;
|
||||
document.getElementById('track-artist').textContent = artist;
|
||||
document.getElementById('stat-match').textContent = matchScore;
|
||||
|
||||
// Fetch metadata
|
||||
const metaRes = await fetch(`/api/metadata?track=${encodeURIComponent(title)}&artist=${encodeURIComponent(artist)}`);
|
||||
const metaData = await metaRes.json();
|
||||
|
||||
if (metaData.status === 'success') {
|
||||
const { key, bpm, tags } = metaData.data;
|
||||
|
||||
currentTrack = { title, artist, key, bpm };
|
||||
|
||||
document.getElementById('stat-key').textContent = key;
|
||||
document.getElementById('stat-key-sub').textContent = window.camelotToMusicKey ? window.camelotToMusicKey[key] : '';
|
||||
document.getElementById('stat-bpm').textContent = bpm;
|
||||
|
||||
const tagsContainer = document.getElementById('tags-row');
|
||||
tagsContainer.innerHTML = '';
|
||||
|
||||
const keyTag = document.createElement('span');
|
||||
keyTag.className = 'tag tag-key';
|
||||
keyTag.textContent = key;
|
||||
keyTag.style.backgroundColor = window.colorMatrix ? window.colorMatrix[key] : 'var(--key-current)';
|
||||
tagsContainer.appendChild(keyTag);
|
||||
|
||||
tags.forEach(t => {
|
||||
const el = document.createElement('span');
|
||||
el.className = 'tag tag-genre';
|
||||
el.textContent = t;
|
||||
tagsContainer.appendChild(el);
|
||||
});
|
||||
|
||||
window.updateWheel(key);
|
||||
document.documentElement.style.setProperty('--key-current', window.colorMatrix ? window.colorMatrix[key] : '#4F9D69');
|
||||
}
|
||||
|
||||
updateStatus(`Erkannt: ${title}`, 'active');
|
||||
|
||||
// Select track in UI and load details/suggestions
|
||||
window.selectTrack({
|
||||
title,
|
||||
artist,
|
||||
cover_url,
|
||||
camelot_key: key,
|
||||
bpm: bpm,
|
||||
is_corrected: isCorrected,
|
||||
is_local: !!key
|
||||
});
|
||||
|
||||
} else if (json.status === 'not_configured') {
|
||||
updateStatus(json.message, 'warning');
|
||||
} else {
|
||||
const sourceLabel = currentSource === 'system' ? 'PC-Audio' : 'Mikrofon';
|
||||
updateStatus(`Höre ${sourceLabel}...`, 'active');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error processing audio", e);
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Audio] Recognition network error:', err);
|
||||
updateStatus('Erkennungsfehler', 'warning');
|
||||
} finally {
|
||||
isProcessingAudio = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleListening() {
|
||||
isListening = !isListening;
|
||||
function updateStatus(text, type = 'normal') {
|
||||
const statusEl = document.getElementById('listening-status');
|
||||
const icon = statusEl.querySelector('i');
|
||||
const text = document.getElementById('listening-text');
|
||||
|
||||
if (isListening) {
|
||||
statusEl.classList.remove('paused');
|
||||
icon.className = 'fa-solid fa-microphone';
|
||||
text.textContent = 'Listening';
|
||||
startRecordingLoop();
|
||||
} else {
|
||||
statusEl.classList.add('paused');
|
||||
icon.className = 'fa-solid fa-pause';
|
||||
text.textContent = 'Paused';
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
}
|
||||
const textEl = document.getElementById('listening-text');
|
||||
if (!textEl) return;
|
||||
|
||||
textEl.textContent = text;
|
||||
statusEl.className = 'status ' + type;
|
||||
}
|
||||
|
||||
// Initial state
|
||||
window.updateWheel(null);
|
||||
startRecordingLoop();
|
||||
// Hero View Toggles
|
||||
function toggleWheel() {
|
||||
const hero = document.getElementById('hero');
|
||||
if (hero.classList.contains('wheel-mode')) {
|
||||
@@ -165,4 +351,18 @@ function toggleCover() {
|
||||
|
||||
function resetHero() {
|
||||
document.getElementById('hero').classList.remove('wheel-mode', 'cover-mode');
|
||||
}
|
||||
}
|
||||
|
||||
// Expose globals
|
||||
window.toggleTheme = toggleTheme;
|
||||
window.changeAudioSource = changeAudioSource;
|
||||
window.selectSpecificDevice = selectSpecificDevice;
|
||||
window.toggleListening = toggleListening;
|
||||
window.toggleWheel = toggleWheel;
|
||||
window.toggleCover = toggleCover;
|
||||
window.resetHero = resetHero;
|
||||
|
||||
// Initial state
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.updateWheel(null);
|
||||
});
|
||||
+99
-26
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>KeySelector</title>
|
||||
<title>KeySelector — Harmonic DJ Assistant</title>
|
||||
<link rel="icon" type="image/png" href="favicon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
@@ -13,68 +13,141 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Topbar -->
|
||||
<div class="topbar">
|
||||
<div class="brand">KeySelector</div>
|
||||
<div class="topbar-right">
|
||||
<div class="status" id="listening-status" onclick="toggleListening()">
|
||||
<i class="fa-solid fa-microphone"></i>
|
||||
<span id="listening-text">Listening</span>
|
||||
<div class="topbar-left">
|
||||
<div class="brand">KEYSELECTOR</div>
|
||||
<div class="audio-source-wrapper">
|
||||
<select id="audio-source-select" class="source-select" onchange="changeAudioSource(this.value)">
|
||||
<option value="system">🖥️ PC / System-Audio (VLC, DJ, YouTube)</option>
|
||||
<option value="mic">🎤 Mikrofon (Raum / Pult)</option>
|
||||
<option value="device">🎙️ Spezifisches Eingabegerät...</option>
|
||||
</select>
|
||||
<select id="audio-device-select" class="source-select" style="display: none;" onchange="selectSpecificDevice(this.value)">
|
||||
<option value="">Gerät wählen...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<!-- Live Audio Level Meter -->
|
||||
<div class="audio-meter-container" title="Audio-Eingangspegel">
|
||||
<div class="audio-meter-bar" id="audio-meter-fill"></div>
|
||||
</div>
|
||||
|
||||
<div class="status" id="listening-status" onclick="toggleListening()" title="Klicken zum Pausieren / Fortsetzen">
|
||||
<i class="fa-solid fa-microphone"></i>
|
||||
<span id="listening-text">Bereit</span>
|
||||
</div>
|
||||
|
||||
<button class="theme-toggle" id="theme-btn" onclick="toggleTheme()">Light</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Search Bar -->
|
||||
<div class="search-box-container">
|
||||
<div class="search-input-wrapper">
|
||||
<i class="fa-solid fa-magnifying-glass search-icon"></i>
|
||||
<input type="text" id="search-input" placeholder="Song oder Artist suchen (z. B. Daft Punk, Fisher, Avicii)..." autocomplete="off">
|
||||
<button id="search-clear-btn" class="search-clear" onclick="clearSearch()" style="display:none">×</button>
|
||||
</div>
|
||||
<div id="search-results-dropdown" class="search-results-dropdown" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- Hero Card -->
|
||||
<div class="hero" id="hero">
|
||||
<div class="cover" id="cover" onclick="toggleCover()" title="Click to expand">
|
||||
<div class="cover" id="cover" onclick="toggleCover()" title="Klicken zum Vergrößern">
|
||||
<div class="cover-placeholder">
|
||||
<span class="note">♪</span>
|
||||
<span>No Cover</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<div class="info-top">
|
||||
<button class="search-fallback-btn" onclick="promptManualSearch()"><i class="fa-solid fa-magnifying-glass"></i> Search</button>
|
||||
<div class="track-title" id="track-title">Waiting...</div>
|
||||
<div class="track-artist" id="track-artist">for audio input</div>
|
||||
<div class="track-title" id="track-title">Warte auf Audio...</div>
|
||||
<div class="track-artist" id="track-artist">Starte PC-Audio oder Mikrofon oben rechts</div>
|
||||
</div>
|
||||
|
||||
<div class="info-mid">
|
||||
<div class="stats-row">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Key</div>
|
||||
<div class="stat" onclick="openKeyBpmModal()" title="Klicken zum Korrigieren von Key & BPM">
|
||||
<div class="stat-header">
|
||||
<span class="stat-label">Key</span>
|
||||
<i class="fa-solid fa-pen-to-square edit-btn"></i>
|
||||
</div>
|
||||
<div class="stat-value k" id="stat-key">-</div>
|
||||
<div class="stat-sub" id="stat-key-sub">-</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">BPM</div>
|
||||
|
||||
<div class="stat" onclick="openKeyBpmModal()" title="Klicken zum Korrigieren von Key & BPM">
|
||||
<div class="stat-header">
|
||||
<span class="stat-label">BPM</span>
|
||||
<i class="fa-solid fa-pen-to-square edit-btn"></i>
|
||||
</div>
|
||||
<div class="stat-value b" id="stat-bpm">-</div>
|
||||
<div class="stat-sub">-</div>
|
||||
<div class="stat-sub" id="stat-bpm-sub">± 0</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-label">Match</div>
|
||||
<div class="stat-header">
|
||||
<span class="stat-label">Status</span>
|
||||
</div>
|
||||
<div class="stat-value m" id="stat-match">-</div>
|
||||
<div class="stat-sub">acoustid</div>
|
||||
<div class="stat-sub" id="stat-source-sub">Lokal / DB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tags-row" id="tags-row">
|
||||
<!-- Tags will be populated here -->
|
||||
</div>
|
||||
|
||||
<div class="tags-row" id="tags-row"></div>
|
||||
</div>
|
||||
|
||||
<div class="info-bot">
|
||||
<div class="wheel-container" id="wheel-container" onclick="toggleWheel()" title="Click to expand">
|
||||
<div class="wheel-container" id="wheel-container" onclick="toggleWheel()" title="Klicken für Vollansicht des Camelot Wheels">
|
||||
<svg viewBox="-110 -110 220 220" id="wheel-small" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="close-btn" id="close-btn" onclick="resetHero()" title="Close">×</button>
|
||||
|
||||
<button class="close-btn" id="close-btn" onclick="resetHero()" title="Schließen">×</button>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Up Next</div>
|
||||
<div class="suggestions" id="suggestions"></div>
|
||||
<!-- Suggestions Grid -->
|
||||
<div class="section-title">Harmonisch passende Folge-Tracks (Up Next)</div>
|
||||
<div class="suggestions" id="suggestions">
|
||||
<div style="color:var(--text-dim);font-size:12px;font-family:'JetBrains Mono',monospace;">Wähle oder spiele einen Track ab, um harmonische Übergänge zu berechnen.</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 40px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-dim);">
|
||||
BPM & Key data provided by <a href="https://getsongbpm.com" target="_blank" style="color: var(--text-dim); text-decoration: underline;">GetSongBPM</a>
|
||||
KeySelector — Local-First PostgreSQL DJ Suite
|
||||
<br>
|
||||
<span style="opacity: 0.5;">v1.0.2</span>
|
||||
<span style="opacity: 0.5;">v2.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key & BPM Correction Modal -->
|
||||
<div id="edit-modal" class="modal-overlay" style="display:none;" onclick="closeModalOnOverlay(event)">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-track-title">Track-Daten korrigieren</h3>
|
||||
<button class="modal-close" onclick="closeKeyBpmModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-desc">Werte werden dauerhaft in deiner lokalen PostgreSQL-Datenbank gespeichert und haben bei künftigen Suchen/Erkennungen Vorrang.</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-key-select">Camelot Key:</label>
|
||||
<select id="edit-key-select" class="form-input"></select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-bpm-input">BPM:</label>
|
||||
<input type="number" id="edit-bpm-input" class="form-input" min="60" max="220" step="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeKeyBpmModal()">Abbrechen</button>
|
||||
<button class="btn btn-primary" onclick="saveKeyBpmCorrection()">In PostgreSQL speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+357
-106
@@ -1,115 +1,366 @@
|
||||
function promptManualSearch() {
|
||||
const query = prompt("Enter track name and artist manually (e.g. 'Alphaville Big in Japan'):");
|
||||
if (query && query.trim()) {
|
||||
simulateRecognition(query.trim());
|
||||
// Live Search & PostgreSQL Correction Management
|
||||
|
||||
let searchTimeout = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const searchDropdown = document.getElementById('search-results-dropdown');
|
||||
const clearBtn = document.getElementById('search-clear-btn');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const val = e.target.value.trim();
|
||||
clearBtn.style.display = val ? 'block' : 'none';
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
if (!val) {
|
||||
searchDropdown.style.display = 'none';
|
||||
searchDropdown.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
executeSearch(val);
|
||||
}, 250);
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.search-box-container')) {
|
||||
searchDropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Populate Key options in edit modal
|
||||
initEditKeyOptions();
|
||||
});
|
||||
|
||||
function clearSearch() {
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const searchDropdown = document.getElementById('search-results-dropdown');
|
||||
const clearBtn = document.getElementById('search-clear-btn');
|
||||
searchInput.value = '';
|
||||
clearBtn.style.display = 'none';
|
||||
searchDropdown.style.display = 'none';
|
||||
searchDropdown.innerHTML = '';
|
||||
}
|
||||
|
||||
async function executeSearch(query) {
|
||||
const searchDropdown = document.getElementById('search-results-dropdown');
|
||||
searchDropdown.innerHTML = '<div style="padding:14px;color:var(--text-dim);font-size:12px;font-family:monospace;">Suche in PostgreSQL & Online...</div>';
|
||||
searchDropdown.style.display = 'block';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
const json = await res.json();
|
||||
|
||||
if (json.status !== 'success' || !json.data || json.data.length === 0) {
|
||||
searchDropdown.innerHTML = '<div style="padding:14px;color:var(--text-dim);font-size:12px;font-family:monospace;">Keine Tracks gefunden.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
searchDropdown.innerHTML = '';
|
||||
json.data.forEach(track => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'search-result-item';
|
||||
|
||||
const coverHtml = track.cover_url
|
||||
? `<img src="${track.cover_url}" alt="cover" loading="lazy">`
|
||||
: `<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-dim);font-size:16px;">♪</div>`;
|
||||
|
||||
let badgesHtml = '';
|
||||
if (track.is_local && track.camelot_key) {
|
||||
const color = window.colorMatrix ? window.colorMatrix[track.camelot_key] : '#4F9D69';
|
||||
badgesHtml += `<span class="search-badge" style="background:${color};">${track.camelot_key}</span>`;
|
||||
if (track.bpm) {
|
||||
badgesHtml += `<span class="search-badge-bpm">${track.bpm} BPM</span>`;
|
||||
}
|
||||
badgesHtml += `<span class="search-badge search-badge-local">${track.is_corrected ? 'KORRIGIERT' : 'LOKAL'}</span>`;
|
||||
} else {
|
||||
badgesHtml += `<span class="search-badge" style="background:var(--surface-2);color:var(--text-dim);border:1px solid var(--border);">NEU</span>`;
|
||||
}
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="search-result-cover">${coverHtml}</div>
|
||||
<div class="search-result-info">
|
||||
<div class="search-result-title">${escapeHtml(track.title)}</div>
|
||||
<div class="search-result-artist">${escapeHtml(track.artist)}</div>
|
||||
</div>
|
||||
<div class="search-result-badges">${badgesHtml}</div>
|
||||
`;
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
selectTrack(track);
|
||||
searchDropdown.style.display = 'none';
|
||||
clearSearch();
|
||||
});
|
||||
|
||||
searchDropdown.appendChild(item);
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Search] Error:', err);
|
||||
searchDropdown.innerHTML = '<div style="padding:14px;color:#D62828;font-size:12px;">Suchfehler aufgetreten.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSuggestions(currentKey) {
|
||||
const sugContainer = document.getElementById('suggestions');
|
||||
sugContainer.innerHTML = '';
|
||||
|
||||
if (!currentKey || !window.mixingRules[currentKey]) {
|
||||
sugContainer.innerHTML = '<div style="color:var(--text-dim);font-size:12px;">No suggestions available.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create some dummy suggestions based on the rules for the mockup
|
||||
const compatKeys = window.mixingRules[currentKey].slice(1);
|
||||
const dummySuggestions = compatKeys.map((c, i) => {
|
||||
return {
|
||||
c: c,
|
||||
m: window.camelotToMusicKey[c],
|
||||
color: window.colorMatrix[c],
|
||||
title: `Dummy Track ${i+1}`,
|
||||
artist: `Artist ${c}`,
|
||||
bpm: 120 + Math.floor(Math.random() * 10 - 5),
|
||||
compat: i + 1 // 1=Quinte-1, 2=Quinte+1, etc.
|
||||
};
|
||||
});
|
||||
|
||||
dummySuggestions.forEach(s => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'sug';
|
||||
const idSafe = `sug-cover-${s.c.replace(/\s/g,'_')}-${s.title.replace(/[^A-Za-z0-9]/g,'')}`;
|
||||
div.innerHTML = `
|
||||
<div class="sug-cover" id="${idSafe}">
|
||||
<div class="sug-cover-placeholder">♪</div>
|
||||
</div>
|
||||
<div class="sug-meta">
|
||||
<div class="sug-key" style="background:${s.color}">${s.c}</div>
|
||||
<div class="sug-bpm">${s.bpm} BPM</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="sug-title">${s.title}</div>
|
||||
<div class="sug-artist">${s.artist} · ${s.m}</div>
|
||||
</div>
|
||||
<div class="sug-compat">${window.compatLabels[s.compat]}</div>
|
||||
`;
|
||||
sugContainer.appendChild(div);
|
||||
window.fetchCover(`${s.artist} ${s.title}`, div.querySelector('.sug-cover'));
|
||||
});
|
||||
}
|
||||
|
||||
// Temporary simulation function to show the UI working
|
||||
function simulateRecognition(query) {
|
||||
// Try to extract artist and title simply
|
||||
const parts = query.split(' ');
|
||||
const title = parts.slice(0, Math.max(1, parts.length - 1)).join(' ');
|
||||
const artist = parts[parts.length - 1] || 'Unknown';
|
||||
|
||||
// Pick a random key for the demo
|
||||
const keysArr = Object.keys(window.colorMatrix);
|
||||
const randomKey = keysArr[Math.floor(Math.random() * keysArr.length)];
|
||||
const bpm = 110 + Math.floor(Math.random() * 30);
|
||||
|
||||
updateHero({
|
||||
title,
|
||||
artist,
|
||||
key: randomKey,
|
||||
bpm: bpm,
|
||||
match: '99%',
|
||||
tags: [randomKey, 'Electronic', 'Pop']
|
||||
});
|
||||
}
|
||||
|
||||
function updateHero(track) {
|
||||
window.currentTrack = track;
|
||||
|
||||
async function selectTrack(track) {
|
||||
// Update Hero UI
|
||||
document.getElementById('track-title').textContent = track.title;
|
||||
document.getElementById('track-artist').textContent = track.artist;
|
||||
|
||||
document.getElementById('stat-key').textContent = track.key;
|
||||
document.getElementById('stat-key').style.color = window.colorMatrix[track.key];
|
||||
document.getElementById('stat-key-sub').textContent = window.camelotToMusicKey[track.key];
|
||||
|
||||
document.getElementById('stat-bpm').textContent = track.bpm;
|
||||
document.getElementById('stat-match').textContent = track.match;
|
||||
|
||||
// Update tags
|
||||
const tagsRow = document.getElementById('tags-row');
|
||||
tagsRow.innerHTML = '';
|
||||
track.tags.forEach(tag => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'tag ' + (tag === track.key ? 'tag-key' : 'tag-genre');
|
||||
if (tag === track.key) {
|
||||
span.style.background = window.colorMatrix[track.key];
|
||||
document.getElementById('stat-match').textContent = track.is_corrected ? '100%' : 'Online';
|
||||
document.getElementById('stat-source-sub').textContent = track.is_local ? (track.is_corrected ? 'PostgreSQL (Fix)' : 'PostgreSQL') : 'Deezer / Online';
|
||||
|
||||
// Update Cover
|
||||
if (track.cover_url) {
|
||||
const coverEl = document.getElementById('cover');
|
||||
coverEl.innerHTML = `<img src="${track.cover_url}" alt="cover">`;
|
||||
} else {
|
||||
window.fetchCover(`${track.artist} ${track.title}`, document.getElementById('cover'));
|
||||
}
|
||||
|
||||
// Fetch or apply metadata
|
||||
let key = track.camelot_key;
|
||||
let bpm = track.bpm;
|
||||
let tags = track.tags || ['dance'];
|
||||
|
||||
if (!key || !bpm) {
|
||||
// Query metadata route to get key, bpm, and persist into PostgreSQL
|
||||
try {
|
||||
const metaRes = await fetch(`/api/metadata?track=${encodeURIComponent(track.title)}&artist=${encodeURIComponent(track.artist)}`);
|
||||
const metaJson = await metaRes.json();
|
||||
if (metaJson.status === 'success') {
|
||||
key = metaJson.data.key;
|
||||
bpm = metaJson.data.bpm;
|
||||
tags = metaJson.data.tags || tags;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Search] Metadata fetch failed, using fallback', e);
|
||||
key = '8B';
|
||||
bpm = 120;
|
||||
}
|
||||
span.textContent = tag;
|
||||
tagsRow.appendChild(span);
|
||||
});
|
||||
|
||||
// Update Hero Border
|
||||
document.documentElement.style.setProperty('--key-current', window.colorMatrix[track.key]);
|
||||
|
||||
// Update Wheel
|
||||
window.updateWheel(track.key);
|
||||
|
||||
// Fetch Cover
|
||||
window.fetchCover(`${track.artist} ${track.title}`, document.getElementById('cover'));
|
||||
|
||||
// Update Suggestions
|
||||
renderSuggestions(track.key);
|
||||
}
|
||||
|
||||
window.currentTrack = {
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
key: key,
|
||||
bpm: bpm,
|
||||
cover_url: track.cover_url
|
||||
};
|
||||
|
||||
updateHeroStats(key, bpm, tags);
|
||||
renderSuggestions(key, bpm, track.artist, track.title);
|
||||
}
|
||||
|
||||
function updateHeroStats(key, bpm, tags = []) {
|
||||
document.getElementById('stat-key').textContent = key;
|
||||
const musicKey = window.camelotToMusicKey ? window.camelotToMusicKey[key] : '';
|
||||
document.getElementById('stat-key-sub').textContent = musicKey;
|
||||
|
||||
const keyColor = window.colorMatrix ? window.colorMatrix[key] : '#4F9D69';
|
||||
document.getElementById('stat-key').style.color = keyColor;
|
||||
document.documentElement.style.setProperty('--key-current', keyColor);
|
||||
|
||||
document.getElementById('stat-bpm').textContent = bpm;
|
||||
document.getElementById('stat-bpm-sub').textContent = `± ${Math.round(bpm * 0.04)} BPM`;
|
||||
|
||||
// Render Tags
|
||||
const tagsRow = document.getElementById('tags-row');
|
||||
tagsRow.innerHTML = '';
|
||||
|
||||
const keyTag = document.createElement('span');
|
||||
keyTag.className = 'tag tag-key';
|
||||
keyTag.textContent = `${key} · ${musicKey}`;
|
||||
keyTag.style.backgroundColor = keyColor;
|
||||
tagsRow.appendChild(keyTag);
|
||||
|
||||
tags.forEach(t => {
|
||||
const el = document.createElement('span');
|
||||
el.className = 'tag tag-genre';
|
||||
el.textContent = t;
|
||||
tagsRow.appendChild(el);
|
||||
});
|
||||
|
||||
// Update Camelot Wheel
|
||||
window.updateWheel(key);
|
||||
}
|
||||
|
||||
// Render real harmonic suggestions from PostgreSQL
|
||||
async function renderSuggestions(currentKey, currentBpm = 120, artist = '', title = '') {
|
||||
const sugContainer = document.getElementById('suggestions');
|
||||
if (!sugContainer) return;
|
||||
|
||||
if (!currentKey) {
|
||||
sugContainer.innerHTML = '<div style="color:var(--text-dim);font-size:12px;font-family:monospace;">Wähle einen Track für Vorschläge.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
sugContainer.innerHTML = '<div style="color:var(--text-dim);font-size:12px;font-family:monospace;">Berechne harmonische Übergänge aus PostgreSQL...</div>';
|
||||
|
||||
try {
|
||||
const url = `/api/suggestions?key=${encodeURIComponent(currentKey)}&bpm=${currentBpm}&artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`;
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
|
||||
if (json.status !== 'success' || !json.data || json.data.length === 0) {
|
||||
sugContainer.innerHTML = '<div style="color:var(--text-dim);font-size:12px;font-family:monospace;">Keine passenden Tracks in der Datenbank gefunden. Suche weitere Tracks, um deine Library zu erweitern.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
sugContainer.innerHTML = '';
|
||||
json.data.forEach(s => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'sug';
|
||||
const idSafe = `sug-${s.c}-${(s.title || '').replace(/[^A-Za-z0-9]/g, '')}`;
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="sug-cover" id="${idSafe}">
|
||||
${s.cover_url ? `<img src="${s.cover_url}" alt="cover" loading="lazy">` : '<div class="sug-cover-placeholder">♪</div>'}
|
||||
</div>
|
||||
<div class="sug-meta">
|
||||
<div class="sug-key" style="background:${s.color}">${s.c}</div>
|
||||
<div class="sug-bpm">${s.bpm} BPM</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="sug-title" title="${escapeHtml(s.title)}">${escapeHtml(s.title)}</div>
|
||||
<div class="sug-artist">${escapeHtml(s.artist)} · ${s.m}</div>
|
||||
</div>
|
||||
<div class="sug-compat">${s.compatLabel || 'KOMPATIBEL'}</div>
|
||||
`;
|
||||
|
||||
// Click to transition to this song as next current track!
|
||||
div.addEventListener('click', () => {
|
||||
selectTrack(s);
|
||||
});
|
||||
|
||||
sugContainer.appendChild(div);
|
||||
|
||||
// Lazy load cover if not already available
|
||||
if (!s.cover_url) {
|
||||
window.fetchCover(`${s.artist} ${s.title}`, div.querySelector('.sug-cover'));
|
||||
}
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error('[Suggestions] Error loading suggestions:', e);
|
||||
sugContainer.innerHTML = '<div style="color:#D62828;font-size:12px;">Fehler beim Laden der Vorschläge.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Key & BPM Correction Modal
|
||||
function initEditKeyOptions() {
|
||||
const select = document.getElementById('edit-key-select');
|
||||
if (!select) return;
|
||||
|
||||
const camelotKeys = [
|
||||
['12B', 'F'], ['12A', 'Dm'],
|
||||
['1B', 'C'], ['1A', 'Am'],
|
||||
['2B', 'G'], ['2A', 'Em'],
|
||||
['3B', 'D'], ['3A', 'Bm'],
|
||||
['4B', 'A'], ['4A', 'F#m'],
|
||||
['5B', 'E'], ['5A', 'C#m'],
|
||||
['6B', 'B'], ['6A', 'G#m'],
|
||||
['7B', 'F#'], ['7A', 'D#m'],
|
||||
['8B', 'Db'], ['8A', 'Bbm'],
|
||||
['9B', 'Ab'], ['9A', 'Fm'],
|
||||
['10B', 'Eb'], ['10A', 'Cm'],
|
||||
['11B', 'Bb'], ['11A', 'Gm'],
|
||||
];
|
||||
|
||||
select.innerHTML = '';
|
||||
camelotKeys.forEach(([c, m]) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c;
|
||||
opt.textContent = `${c} — ${m}`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function openKeyBpmModal() {
|
||||
if (!window.currentTrack || !window.currentTrack.title) {
|
||||
alert('Bitte wähle zuerst einen Track aus oder spiele einen Song ab.');
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.getElementById('edit-modal');
|
||||
const titleEl = document.getElementById('modal-track-title');
|
||||
const keySelect = document.getElementById('edit-key-select');
|
||||
const bpmInput = document.getElementById('edit-bpm-input');
|
||||
|
||||
titleEl.textContent = `Daten korrigieren: ${window.currentTrack.artist} – ${window.currentTrack.title}`;
|
||||
if (window.currentTrack.key) keySelect.value = window.currentTrack.key;
|
||||
if (window.currentTrack.bpm) bpmInput.value = window.currentTrack.bpm;
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeKeyBpmModal() {
|
||||
const modal = document.getElementById('edit-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function closeModalOnOverlay(e) {
|
||||
if (e.target.classList.contains('modal-overlay')) {
|
||||
closeKeyBpmModal();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveKeyBpmCorrection() {
|
||||
if (!window.currentTrack) return;
|
||||
|
||||
const keySelect = document.getElementById('edit-key-select');
|
||||
const bpmInput = document.getElementById('edit-bpm-input');
|
||||
|
||||
const newKey = keySelect.value;
|
||||
const newBpm = parseInt(bpmInput.value, 10);
|
||||
|
||||
if (!newKey || isNaN(newBpm)) {
|
||||
alert('Bitte gültigen Key und BPM eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/tracks/correct', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
artist: window.currentTrack.artist,
|
||||
title: window.currentTrack.title,
|
||||
camelotKey: newKey,
|
||||
bpm: newBpm
|
||||
})
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
if (json.status === 'success') {
|
||||
window.currentTrack.key = newKey;
|
||||
window.currentTrack.bpm = newBpm;
|
||||
window.currentTrack.is_corrected = true;
|
||||
|
||||
document.getElementById('stat-source-sub').textContent = 'PostgreSQL (Korrigiert)';
|
||||
updateHeroStats(newKey, newBpm, window.currentTrack.tags || ['dance']);
|
||||
renderSuggestions(newKey, newBpm, window.currentTrack.artist, window.currentTrack.title);
|
||||
closeKeyBpmModal();
|
||||
} else {
|
||||
alert('Speichern fehlgeschlagen: ' + (json.error || 'Serverfehler'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Correction save error:', err);
|
||||
alert('Verbindungsfehler beim Speichern in PostgreSQL.');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
window.renderSuggestions = renderSuggestions;
|
||||
window.selectTrack = selectTrack;
|
||||
window.openKeyBpmModal = openKeyBpmModal;
|
||||
window.closeKeyBpmModal = closeKeyBpmModal;
|
||||
window.saveKeyBpmCorrection = saveKeyBpmCorrection;
|
||||
window.closeModalOnOverlay = closeModalOnOverlay;
|
||||
+199
-20
@@ -10,6 +10,7 @@
|
||||
--bpm: #E0B423;
|
||||
--match: #4F9D69;
|
||||
--mic: #D62828;
|
||||
--accent: #3B7DD8;
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--bg: #F4F4F8;
|
||||
@@ -23,6 +24,7 @@
|
||||
--bpm: #B88A1A;
|
||||
--match: #2E7A48;
|
||||
--mic: #C4202A;
|
||||
--accent: #2B60B8;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
@@ -35,12 +37,48 @@ html, body {
|
||||
body { padding: 24px 32px; min-height: 100vh; }
|
||||
.container { max-width: 1600px; margin: 0 auto; }
|
||||
|
||||
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.brand { font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 700; letter-spacing: 0.3em; }
|
||||
.topbar-right { display: flex; align-items: center; gap: 18px; }
|
||||
/* Topbar */
|
||||
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; }
|
||||
.topbar-left { display: flex; align-items: center; gap: 16px; }
|
||||
.brand { font-family: 'JetBrains Mono', monospace; font-size: 13px; font-weight: 700; letter-spacing: 0.3em; color: var(--text-bright); }
|
||||
|
||||
.audio-source-wrapper { display: flex; align-items: center; gap: 8px; }
|
||||
.source-select {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.source-select:hover, .source-select:focus { border-color: var(--text-dim); }
|
||||
|
||||
.topbar-right { display: flex; align-items: center; gap: 16px; }
|
||||
|
||||
/* Live Audio Level Meter */
|
||||
.audio-meter-container {
|
||||
width: 50px; height: 10px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.audio-meter-bar {
|
||||
height: 100%; width: 0%;
|
||||
background: linear-gradient(90deg, #4F9D69, #E0B423, #D62828);
|
||||
border-radius: 3px;
|
||||
transition: width 0.08s ease;
|
||||
}
|
||||
|
||||
.status { display: flex; align-items: center; gap: 8px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-dim); letter-spacing: 0.12em; text-transform: uppercase; cursor: pointer; transition: color 0.12s; }
|
||||
.status:hover { color: var(--text); }
|
||||
.status i { color: var(--mic); font-size: 14px; }
|
||||
.status.active i { color: #4F9D69; }
|
||||
.status.paused i { color: var(--text-dim); }
|
||||
|
||||
.theme-toggle {
|
||||
@@ -51,6 +89,87 @@ body { padding: 24px 32px; min-height: 100vh; }
|
||||
}
|
||||
.theme-toggle:hover { color: var(--text); border-color: var(--text-dim); }
|
||||
|
||||
/* Search Box Container */
|
||||
.search-box-container {
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
z-index: 20;
|
||||
}
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute; left: 16px;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
#search-input {
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 40px 12px 44px;
|
||||
color: var(--text-bright);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
#search-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(59, 125, 216, 0.2);
|
||||
}
|
||||
.search-clear {
|
||||
position: absolute; right: 14px;
|
||||
background: none; border: none;
|
||||
color: var(--text-dim); font-size: 18px;
|
||||
cursor: pointer; line-height: 1;
|
||||
}
|
||||
.search-clear:hover { color: var(--text-bright); }
|
||||
|
||||
/* Search Results Dropdown */
|
||||
.search-results-dropdown {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,0.4);
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
z-index: 30;
|
||||
}
|
||||
.search-result-item {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background .1s;
|
||||
}
|
||||
.search-result-item:last-child { border-bottom: none; }
|
||||
.search-result-item:hover { background: var(--surface-2); }
|
||||
.search-result-cover {
|
||||
width: 44px; height: 44px; border-radius: 4px;
|
||||
background: var(--surface-2); overflow: hidden; flex-shrink: 0;
|
||||
}
|
||||
.search-result-cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.search-result-info { flex: 1; min-width: 0; }
|
||||
.search-result-title { font-size: 13px; font-weight: 600; color: var(--text-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.search-result-artist { font-size: 11px; color: var(--text-dim); }
|
||||
.search-result-badges { display: flex; align-items: center; gap: 6px; }
|
||||
.search-badge {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700;
|
||||
padding: 3px 6px; border-radius: 3px; color: #fff;
|
||||
}
|
||||
.search-badge-local {
|
||||
background: rgba(79, 157, 105, 0.15); color: #4F9D69; border: 1px solid #4F9D69;
|
||||
}
|
||||
.search-badge-bpm {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--bpm);
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
@@ -69,6 +188,7 @@ body { padding: 24px 32px; min-height: 100vh; }
|
||||
width: 3px; background: var(--key-current);
|
||||
transition: background-color .18s;
|
||||
}
|
||||
|
||||
/* WHEEL expanded mode */
|
||||
.hero.wheel-mode {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -80,6 +200,7 @@ body { padding: 24px 32px; min-height: 100vh; }
|
||||
.hero.wheel-mode .info-mid { display: none; }
|
||||
.hero.wheel-mode .info-bot { padding-top: 0; justify-content: center; }
|
||||
.hero.wheel-mode .wheel-container { width: 100%; max-width: 520px; height: 520px; }
|
||||
|
||||
/* COVER expanded mode */
|
||||
.hero.cover-mode { grid-template-columns: 1fr; min-height: 540px; }
|
||||
.hero.cover-mode .info { display: none; }
|
||||
@@ -117,22 +238,20 @@ body { padding: 24px 32px; min-height: 100vh; }
|
||||
.info-mid { display: flex; flex-direction: column; gap: 14px; margin-top: 16px; }
|
||||
.info-bot { margin-top: auto; padding-top: 20px; display: flex; justify-content: flex-end; align-items: flex-end; min-height: 160px; }
|
||||
|
||||
.track-title { font-size: 30px; font-weight: 600; line-height: 1.1; letter-spacing: -0.01em; margin-bottom: 4px; }
|
||||
.track-title { font-size: 28px; font-weight: 600; line-height: 1.15; letter-spacing: -0.01em; margin-bottom: 4px; color: var(--text-bright); }
|
||||
.track-artist { font-size: 15px; color: var(--text-dim); }
|
||||
|
||||
.search-fallback-btn {
|
||||
position: absolute; top: 0; right: 0;
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 6px 12px; color: var(--text-dim); font-size: 11px; cursor: pointer;
|
||||
font-family: 'JetBrains Mono', monospace; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
transition: all .12s;
|
||||
}
|
||||
.search-fallback-btn:hover { background: var(--border); color: var(--text); }
|
||||
.search-fallback-btn i { margin-right: 6px; }
|
||||
|
||||
.stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
||||
.stat { background: var(--surface); padding: 12px 14px; }
|
||||
.stat-label { font-family: 'JetBrains Mono', monospace; font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 6px; }
|
||||
.stat {
|
||||
background: var(--surface); padding: 12px 14px;
|
||||
cursor: pointer; transition: background .12s;
|
||||
}
|
||||
.stat:hover { background: var(--surface-2); }
|
||||
.stat-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.stat-label { font-family: 'JetBrains Mono', monospace; font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--text-dim); }
|
||||
.edit-btn { font-size: 10px; color: var(--text-dim); opacity: 0.6; transition: opacity .12s, color .12s; }
|
||||
.stat:hover .edit-btn { opacity: 1; color: var(--accent); }
|
||||
|
||||
.stat-value { font-family: 'JetBrains Mono', monospace; font-size: 22px; font-weight: 700; font-feature-settings: 'tnum'; line-height: 1; }
|
||||
.stat-value.k { color: var(--key-current); }
|
||||
.stat-value.b { color: var(--bpm); }
|
||||
@@ -144,8 +263,6 @@ body { padding: 24px 32px; min-height: 100vh; }
|
||||
.tag-key { background: var(--key-current); color: #fff; font-weight: 700; }
|
||||
.tag-genre { background: rgba(90, 138, 229, 0.13); color: #8AA8D8; border: 1px solid rgba(90, 138, 229, 0.32); }
|
||||
[data-theme="light"] .tag-genre { background: rgba(52, 90, 156, 0.10); color: #345A9C; border-color: rgba(52, 90, 156, 0.28); }
|
||||
.tag-mood { background: rgba(214, 41, 102, 0.13); color: #D88096; border: 1px solid rgba(214, 41, 102, 0.32); }
|
||||
[data-theme="light"] .tag-mood { background: rgba(168, 51, 106, 0.10); color: #A8336A; border-color: rgba(168, 51, 106, 0.28); }
|
||||
|
||||
.wheel-container {
|
||||
width: 140px; height: 140px;
|
||||
@@ -180,9 +297,9 @@ body { padding: 24px 32px; min-height: 100vh; }
|
||||
border-radius: 8px; padding: 10px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color .12s, transform .12s;
|
||||
transition: border-color .12s, transform .12s, box-shadow .12s;
|
||||
}
|
||||
.sug:hover { border-color: var(--text-dim); transform: translateY(-2px); }
|
||||
.sug:hover { border-color: var(--text-dim); transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0,0,0,0.3); }
|
||||
.sug-cover { width: 100%; aspect-ratio: 1; border-radius: 4px; background: var(--surface-2); overflow: hidden; position: relative; }
|
||||
.sug-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.sug-cover-placeholder { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 20px; opacity: 0.25; }
|
||||
@@ -196,3 +313,65 @@ body { padding: 24px 32px; min-height: 100vh; }
|
||||
text-transform: uppercase; color: var(--text-dim);
|
||||
padding-top: 4px; border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100; padding: 16px;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
width: 100%; max-width: 440px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
animation: modalFadeIn .18s ease;
|
||||
}
|
||||
@keyframes modalFadeIn {
|
||||
from { opacity: 0; transform: scale(0.96); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h3 { font-size: 15px; font-weight: 600; color: var(--text-bright); }
|
||||
.modal-close {
|
||||
background: none; border: none; font-size: 20px;
|
||||
color: var(--text-dim); cursor: pointer; line-height: 1;
|
||||
}
|
||||
.modal-close:hover { color: var(--text-bright); }
|
||||
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.modal-desc { font-size: 12px; color: var(--text-dim); line-height: 1.4; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-group label { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.1em; }
|
||||
.form-input {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
color: var(--text-bright);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.form-input:focus { border-color: var(--accent); }
|
||||
.modal-footer {
|
||||
display: flex; justify-content: flex-end; gap: 10px;
|
||||
padding: 14px 20px; border-top: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.btn {
|
||||
padding: 8px 16px; border-radius: 6px; font-size: 12px; font-weight: 600;
|
||||
cursor: pointer; transition: all .12s; border: none;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.btn-secondary { background: var(--surface); border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-secondary:hover { border-color: var(--text-dim); }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
Reference in new issue
Block a user