Replace music dropdown with genre quick-select buttons

- One-click genre buttons play random track from that genre
- Active genre highlighted, now-playing bar shows track name
- Only genres with tracks shown, crossfade on genre switch
- M key replays active genre or picks random

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 03:34:44 -06:00
parent f5eabd7dc4
commit 794ad98cf0
3 changed files with 165 additions and 60 deletions
+87 -13
View File
@@ -727,19 +727,6 @@ section h2 {
margin-bottom: 10px; margin-bottom: 10px;
} }
.music-section select optgroup {
color: var(--accent);
font-weight: bold;
font-style: normal;
padding: 4px 0;
}
.music-section select option {
color: var(--text);
font-weight: normal;
padding: 2px 8px;
}
.music-controls { .music-controls {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -766,6 +753,83 @@ section h2 {
accent-color: var(--accent); accent-color: var(--accent);
} }
/* Genre Quick-Select */
.genre-section {
grid-column: span 3;
}
.genre-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.genre-btn {
background: var(--bg);
color: var(--text);
border: 1px solid rgba(232, 121, 29, 0.12);
padding: 6px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
transition: all 0.15s;
white-space: nowrap;
}
.genre-btn:hover {
border-color: var(--accent);
background: #2a1e10;
color: #fff;
}
.genre-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
font-weight: 600;
}
.now-playing {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.now-playing-text {
font-size: 0.75rem;
color: var(--text-muted);
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.now-playing-stop {
background: var(--bg);
color: var(--text);
border: 1px solid rgba(232, 121, 29, 0.15);
padding: 4px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.75rem;
flex-shrink: 0;
transition: all 0.15s;
}
.now-playing-stop:hover {
border-color: var(--accent);
background: #2a1e10;
}
.now-playing-volume {
width: 80px;
flex-shrink: 0;
accent-color: var(--accent);
}
/* Soundboard */ /* Soundboard */
.sounds-section { .sounds-section {
grid-column: span 2; grid-column: span 2;
@@ -1666,6 +1730,16 @@ section h2 {
font-size: 0.8rem; font-size: 0.8rem;
} }
.media-row .genre-section {
grid-column: span 3;
}
@media (max-width: 700px) {
.media-row .genre-section {
grid-column: span 1;
}
}
/* Devon (Intern) */ /* Devon (Intern) */
.message.devon { .message.devon {
border-left: 3px solid var(--devon); border-left: 3px solid var(--devon);
+8 -8
View File
@@ -142,13 +142,13 @@
<!-- Music / Ads / Idents --> <!-- Music / Ads / Idents -->
<div class="media-row"> <div class="media-row">
<section class="music-section"> <section class="music-section genre-section">
<h2>Music</h2> <h2>Music <span class="shortcut-label">M</span></h2>
<select id="track-select"></select> <div id="genre-buttons" class="genre-grid"></div>
<div class="music-controls"> <div id="now-playing" class="now-playing hidden">
<button id="play-btn">Play <span class="shortcut-label">M</span></button> <span id="now-playing-text" class="now-playing-text"></span>
<button id="stop-btn">Stop</button> <button id="stop-btn" class="now-playing-stop">Stop</button>
<input type="range" id="volume" min="0" max="100" value="30"> <input type="range" id="volume" min="0" max="100" value="30" class="now-playing-volume">
</div> </div>
</section> </section>
@@ -359,6 +359,6 @@
</div> </div>
</div> </div>
<script src="/js/app.js?v=22"></script> <script src="/js/app.js?v=23"></script>
</body> </body>
</html> </html>
+70 -39
View File
@@ -310,7 +310,6 @@ function initEventListeners() {
}); });
// Music - now server-side // Music - now server-side
document.getElementById('play-btn')?.addEventListener('click', playMusic);
document.getElementById('stop-btn')?.addEventListener('click', stopMusic); document.getElementById('stop-btn')?.addEventListener('click', stopMusic);
document.getElementById('volume')?.addEventListener('input', setMusicVolume); document.getElementById('volume')?.addEventListener('input', setMusicVolume);
@@ -964,94 +963,126 @@ async function sendTypedMessage() {
// --- Music (Server-Side) --- // --- Music (Server-Side) ---
let genreMap = {}; // { genre: [track, ...] }
let activeGenre = null;
let currentTrackName = '';
async function loadMusic() { async function loadMusic() {
try { try {
const res = await fetch('/api/music'); const res = await fetch('/api/music');
const data = await res.json(); const data = await res.json();
tracks = data.tracks || []; tracks = data.tracks || [];
const select = document.getElementById('track-select');
if (!select) return;
const previousValue = select.value;
select.innerHTML = '';
// Group tracks by genre // Group tracks by genre
const genres = {}; genreMap = {};
tracks.forEach(track => { tracks.forEach(track => {
const genre = track.genre || 'Other'; const genre = track.genre || 'Other';
if (!genres[genre]) genres[genre] = []; if (!genreMap[genre]) genreMap[genre] = [];
genres[genre].push(track); genreMap[genre].push(track);
}); });
// Sort genre names, but put "Other" last // Sort genre names, "Other" last
const genreOrder = Object.keys(genres).sort((a, b) => { const genreOrder = Object.keys(genreMap).sort((a, b) => {
if (a === 'Other') return 1; if (a === 'Other') return 1;
if (b === 'Other') return -1; if (b === 'Other') return -1;
return a.localeCompare(b); return a.localeCompare(b);
}); });
// Build genre buttons
const container = document.getElementById('genre-buttons');
if (!container) return;
container.innerHTML = '';
genreOrder.forEach(genre => { genreOrder.forEach(genre => {
const group = document.createElement('optgroup'); const btn = document.createElement('button');
group.label = genre; btn.className = 'genre-btn';
// Shuffle within each genre group btn.textContent = genre;
const genreTracks = genres[genre]; btn.dataset.genre = genre;
for (let i = genreTracks.length - 1; i > 0; i--) { btn.addEventListener('click', () => playGenre(genre));
const j = Math.floor(Math.random() * (i + 1)); container.appendChild(btn);
[genreTracks[i], genreTracks[j]] = [genreTracks[j], genreTracks[i]];
}
genreTracks.forEach(track => {
const option = document.createElement('option');
option.value = track.file;
option.textContent = track.name;
group.appendChild(option);
});
select.appendChild(group);
}); });
// Restore previous selection if it still exists console.log('Loaded', tracks.length, 'tracks in', genreOrder.length, 'genres');
if (previousValue && [...select.options].some(o => o.value === previousValue)) {
select.value = previousValue;
}
console.log('Loaded', tracks.length, 'tracks');
} catch (err) { } catch (err) {
console.error('loadMusic error:', err); console.error('loadMusic error:', err);
} }
} }
async function playMusic() { async function playGenre(genre) {
await loadMusic(); const genreTracks = genreMap[genre];
const select = document.getElementById('track-select'); if (!genreTracks || genreTracks.length === 0) return;
const track = select?.value;
if (!track) return; // Pick a random track from the genre
const track = genreTracks[Math.floor(Math.random() * genreTracks.length)];
try { try {
const res = await fetch('/api/music/play', { const res = await fetch('/api/music/play', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track, action: 'play' }) body: JSON.stringify({ track: track.file, action: 'play' })
}); });
if (!res.ok) throw new Error(res.status); if (!res.ok) throw new Error(res.status);
isMusicPlaying = true; isMusicPlaying = true;
activeGenre = genre;
currentTrackName = track.name;
updateMusicUI();
} catch (err) { } catch (err) {
log('Music play failed: ' + err.message); log('Music play failed: ' + err.message);
} }
} }
async function playMusic() {
// M key toggle — if nothing playing, pick random genre
if (!activeGenre) {
const genres = Object.keys(genreMap);
if (genres.length === 0) {
await loadMusic();
const g = Object.keys(genreMap);
if (g.length === 0) return;
return playGenre(g[Math.floor(Math.random() * g.length)]);
}
return playGenre(genres[Math.floor(Math.random() * genres.length)]);
}
return playGenre(activeGenre);
}
async function stopMusic() { async function stopMusic() {
try { try {
const res = await fetch('/api/music/stop', { method: 'POST' }); const res = await fetch('/api/music/stop', { method: 'POST' });
if (!res.ok) throw new Error(res.status); if (!res.ok) throw new Error(res.status);
isMusicPlaying = false; isMusicPlaying = false;
activeGenre = null;
currentTrackName = '';
updateMusicUI();
} catch (err) { } catch (err) {
log('Music stop failed: ' + err.message); log('Music stop failed: ' + err.message);
} }
} }
function updateMusicUI() {
// Highlight active genre button
document.querySelectorAll('.genre-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.genre === activeGenre);
});
// Show/hide now playing bar
const nowPlaying = document.getElementById('now-playing');
const nowText = document.getElementById('now-playing-text');
if (nowPlaying && nowText) {
if (isMusicPlaying && currentTrackName) {
nowText.textContent = currentTrackName;
nowPlaying.classList.remove('hidden');
} else {
nowPlaying.classList.add('hidden');
}
}
}
let _volumeDebounce = null; let _volumeDebounce = null;
function setMusicVolume(e) { function setMusicVolume(e) {
const volume = e.target.value / 100; const volume = e.target.value / 100;