feat: complete rework with postgresql local-first, system audio capture, live search, and real harmonic suggestions

This commit is contained in:
Jannik committed 2026-09-12 19:30:57 +02:00
1 parent f79843d5b6
commit f854944dc1
1088 files changed
+2119 -198536

No files matched your search

+302 -102
View File
@@ -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
View File
@@ -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
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
window.renderSuggestions = renderSuggestions;
window.selectTrack = selectTrack;
window.openKeyBpmModal = openKeyBpmModal;
window.closeKeyBpmModal = closeKeyBpmModal;
window.saveKeyBpmCorrection = saveKeyBpmCorrection;
window.closeModalOnOverlay = closeModalOnOverlay;
+199 -20
View File
@@ -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; }