initial commit

This commit is contained in:
2026-04-12 16:27:56 -04:00
commit 407e437de1
38 changed files with 2997 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
const audio = document.getElementById('audio');
const listEl = document.getElementById('list');
const nowEl = document.getElementById('now');
const timeEl = document.getElementById('time');
const seekEl = document.getElementById('seek');
const volEl = document.getElementById('vol');
const playBtn = document.getElementById('play');
let currentArtist = null;
let trackList = [];
let trackIdx = -1;
let seeking = false;
audio.volume = 0.8;
function fmt(s) {
s = Math.floor(s);
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
function toggleArtist(header, tracksDiv, name) {
const wasOpen = header.classList.contains('open');
if (wasOpen) {
header.classList.remove('open');
tracksDiv.classList.remove('open');
return;
}
header.classList.add('open');
if (tracksDiv.children.length === 0) {
fetch('/api/tracks?artist=' + encodeURIComponent(name))
.then(r => r.json())
.then(list => {
list.forEach((t, i) => {
const d = document.createElement('div');
d.className = 'track';
d.textContent = t.replace(/\.[^.]+$/, '');
d.onclick = e => {
e.stopPropagation();
selectArtistTracks(name, list);
playTrack(i);
};
tracksDiv.appendChild(d);
});
tracksDiv.classList.add('open');
});
} else {
tracksDiv.classList.add('open');
}
}
function selectArtistTracks(name, list) {
currentArtist = name;
trackList = list;
}
function playTrack(i) {
trackIdx = i;
const track = trackList[i];
audio.src = '/music/' + encodeURIComponent(currentArtist) + '/' + encodeURIComponent(track);
audio.play();
nowEl.innerHTML = '<span>' + currentArtist + '</span> &mdash; ' + track.replace(/\.[^.]+$/, '');
playBtn.innerHTML = '&#9646;&#9646;';
listEl.querySelectorAll('.track').forEach(d => d.classList.remove('active'));
const artistSections = listEl.querySelectorAll('.artist-section');
artistSections.forEach(section => {
const header = section.querySelector('.artist-header');
if (header.dataset.name === currentArtist) {
const tracks = section.querySelectorAll('.track');
tracks.forEach((d, j) => d.classList.toggle('active', j === i));
}
});
}
fetch('/api/artists')
.then(r => r.json())
.then(artists => {
artists.forEach(a => {
const section = document.createElement('div');
section.className = 'artist-section';
const header = document.createElement('div');
header.className = 'artist-header';
header.dataset.name = a.name;
const nameSpan = document.createElement('span');
nameSpan.className = 'artist-name';
nameSpan.textContent = a.name;
const countSpan = document.createElement('span');
countSpan.className = 'artist-count';
countSpan.textContent = a.count;
header.appendChild(nameSpan);
header.appendChild(countSpan);
const tracksDiv = document.createElement('div');
tracksDiv.className = 'track-list';
header.onclick = () => toggleArtist(header, tracksDiv, a.name);
section.appendChild(header);
section.appendChild(tracksDiv);
listEl.appendChild(section);
});
});
playBtn.onclick = () => {
if (audio.paused) {
audio.play();
playBtn.innerHTML = '&#9646;&#9646;';
} else {
audio.pause();
playBtn.innerHTML = '&#9654;';
}
};
document.getElementById('prev').onclick = () => {
if (trackIdx > 0) playTrack(trackIdx - 1);
};
document.getElementById('next').onclick = () => {
if (trackIdx < trackList.length - 1) playTrack(trackIdx + 1);
};
audio.onended = () => {
if (trackIdx < trackList.length - 1) playTrack(trackIdx + 1);
};
audio.ontimeupdate = () => {
if (!audio.duration) return;
timeEl.textContent = fmt(audio.currentTime) + ' / ' + fmt(audio.duration);
if (!seeking) seekEl.value = (audio.currentTime / audio.duration) * 100;
};
seekEl.oninput = () => { seeking = true; };
seekEl.onchange = () => { audio.currentTime = (seekEl.value / 100) * audio.duration; seeking = false; };
volEl.oninput = () => { audio.volume = volEl.value / 100; };
document.onkeydown = e => {
if (e.code === 'Space') { e.preventDefault(); playBtn.click(); }
if (e.code === 'ArrowRight' && e.shiftKey) document.getElementById('next').click();
if (e.code === 'ArrowLeft' && e.shiftKey) document.getElementById('prev').click();
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="64" fill="#080808"/>
<text x="256" y="310" text-anchor="middle" font-family="'Impact','Arial Black',sans-serif" font-weight="bold" font-size="240" fill="#0daa1e" letter-spacing="15">AR</text>
</svg>

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 MiB

+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RADIO</title>
<meta name="theme-color" content="#0daa1e">
<link rel="manifest" href="/manifest.json">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Rubik+Glitch&family=Bebas+Neue&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/radio.css">
</head>
<body>
<div id="reconnect" class="hidden">DISCONNECTED — RECONNECTING...</div>
<div id="buffering" class="hidden">BUFFERING...</div>
<div id="top-bar">
<div id="hxc-btn" class="debug-btn hidden" title="HXC">🤘</div>
<div id="thps-btn" class="debug-btn hidden" title="THPS">🛹</div>
<div id="skip-btn" class="debug-btn hidden" title="skip">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 2l8 6-8 6V2z" fill="currentColor"/>
<rect x="12" y="2" width="2.5" height="12" rx="0.5" fill="currentColor"/>
</svg>
</div>
<div id="car-btn" title="car mode">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M3 9l1.5-4h7L13 9" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<rect x="1.5" y="9" width="13" height="3.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
<circle cx="4.5" cy="12.5" r="1" fill="currentColor"/>
<circle cx="11.5" cy="12.5" r="1" fill="currentColor"/>
</svg>
</div>
<div id="vol-btn" title="volume">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 5.5h2.5L8 2v12L4.5 10.5H2a1 1 0 01-1-1v-3a1 1 0 011-1z" fill="currentColor"/>
<path d="M10.5 4.5c.8.8 1.3 2 1.3 3.5s-.5 2.7-1.3 3.5M12.5 2.5c1.3 1.3 2 3.2 2 5.5s-.7 4.2-2 5.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</div>
</div>
<div id="vol-dropdown" class="hidden">
<input type="range" id="vol" min="0" max="200" value="80" orient="vertical">
</div>
<div id="splash">
<div id="freq">66.67 FM</div>
<div id="title" data-text="ACID RADIO">ACID<br>RADIO</div>
<div id="tagline">HARDCORE x HEAVY x PUNK</div>
<button id="tunein">TUNE IN</button>
<div id="listener-count">0 listening</div>
</div>
<div id="radio" class="hidden">
<div id="radio-header">ACID RADIO</div>
<div id="now-artist"></div>
<div id="now-track"></div>
<div id="now-genre"></div>
<div id="progress-row">
<span id="time-elapsed">0:00</span>
<div id="progress-wrap">
<div id="progress-bar"></div>
</div>
<span id="time-total">0:00</span>
</div>
<div id="votes">
<button id="vote-up" class="vote-btn" title="thumbs up">
<span class="vote-emoji">🤘</span>
<span id="count-up">0</span>
</button>
<button id="vote-down" class="vote-btn" title="thumbs down">
<span class="vote-emoji">👎</span>
<span id="count-down">0</span>
</button>
<button id="vote-skip" class="vote-btn" title="vote to skip">
<span class="vote-emoji"></span>
<span id="skip-vote-count">0</span>
</button>
</div>
<div id="listener-count-radio">0 listening</div>
</div>
<video id="bg-video" muted playsinline></video>
<video id="bg-video2" muted playsinline></video>
<audio id="audio"></audio>
<script src="/radio.js"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{
"name": "ACID RADIO",
"short_name": "ACID RADIO",
"start_url": "/",
"display": "standalone",
"background_color": "#080808",
"theme_color": "#0daa1e",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
+533
View File
@@ -0,0 +1,533 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #080808;
color: #c8c8c8;
font-family: 'Rubik Glitch', 'Impact', 'Arial Black', sans-serif;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
#bg-video, #bg-video2 {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
object-fit: cover;
z-index: -1;
display: none;
}
#bg-video.active, #bg-video2.active {
display: block;
}
body.artist-bg {
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
body.shake {
animation: screen-shake 0.15s linear;
}
@keyframes screen-shake {
0% { transform: translate(0, 0); }
20% { transform: translate(-3px, 2px); }
40% { transform: translate(3px, -2px); }
60% { transform: translate(-2px, -3px); }
80% { transform: translate(2px, 3px); }
100% { transform: translate(0, 0); }
}
::selection {
background: #10e020;
color: #fff;
}
.hidden {
display: none !important;
}
/* ── top bar ── */
#top-bar {
position: fixed;
top: 16px;
right: 16px;
display: flex;
gap: 8px;
z-index: 10;
}
#hxc-btn, #thps-btn, #skip-btn, #car-btn, #vol-btn {
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid #222;
background: #111;
color: #555;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
#hxc-btn:hover, #thps-btn:hover, #skip-btn:hover, #car-btn:hover, #vol-btn:hover {
border-color: #444;
color: #999;
}
#vol-dropdown {
position: fixed;
top: 62px;
right: 16px;
background: #111;
border: 1px solid #222;
border-radius: 6px;
padding: 14px 10px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}
#vol {
-webkit-appearance: none;
appearance: none;
writing-mode: vertical-lr;
direction: rtl;
width: 20px;
height: 120px;
background: transparent;
outline: none;
cursor: pointer;
}
#vol::-webkit-slider-runnable-track {
width: 4px;
background: #1a1a1a;
border-radius: 2px;
}
#vol::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: #555;
border-radius: 50%;
cursor: pointer;
}
#vol::-webkit-slider-thumb:hover {
background: #888;
}
#vol::-moz-range-track {
width: 4px;
background: #1a1a1a;
border-radius: 2px;
border: none;
}
#vol::-moz-range-thumb {
width: 14px;
height: 14px;
background: #555;
border-radius: 50%;
border: none;
cursor: pointer;
}
#vol::-moz-range-thumb:hover {
background: #888;
}
#reconnect {
position: fixed;
top: 0;
left: 0;
width: 100%;
padding: 10px;
background: #c22;
color: #fff;
text-align: center;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 11px;
letter-spacing: 3px;
z-index: 100;
}
/* ── splash ── */
#splash {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
#freq {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
color: #333;
letter-spacing: 4px;
}
#title {
font-size: 100px;
line-height: 1;
color: #fff;
letter-spacing: 4px;
text-shadow: 0 0 40px rgba(16, 224, 32, 0.3);
position: relative;
}
#title::before,
#title::after {
content: 'ACID\ARADIO';
white-space: pre;
position: absolute;
top: 0;
left: 0;
width: 100%;
overflow: hidden;
}
#title::before {
color: #fff;
text-shadow: -2px 0 #10e020;
animation: glitch-top 1.2s infinite linear alternate-reverse;
clip-path: inset(0 0 65% 0);
}
#title::after {
color: #fff;
text-shadow: 2px 0 #0daa1e;
animation: glitch-bottom 1s infinite linear alternate-reverse;
clip-path: inset(60% 0 0 0);
}
@keyframes glitch-top {
0% { transform: translate(0); }
2% { transform: translate(3px, -1px); }
4% { transform: translate(-2px, 1px); }
6% { transform: translate(0); }
40% { transform: translate(0); }
42% { transform: translate(-4px, 0); }
44% { transform: translate(2px, 1px); }
46% { transform: translate(0); }
80% { transform: translate(0); }
82% { transform: translate(5px, 0); }
84% { transform: translate(-3px, -1px); }
86% { transform: translate(0); }
100% { transform: translate(0); }
}
@keyframes glitch-bottom {
0% { transform: translate(0); }
3% { transform: translate(-3px, 1px); }
6% { transform: translate(2px, 0); }
9% { transform: translate(0); }
50% { transform: translate(0); }
53% { transform: translate(4px, 1px); }
56% { transform: translate(-2px, 0); }
59% { transform: translate(0); }
100% { transform: translate(0); }
}
#tagline {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
color: #555;
letter-spacing: 6px;
margin-top: 8px;
}
#listener-count {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 11px;
color: #333;
letter-spacing: 2px;
margin-top: 20px;
}
#listener-count-radio {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 10px;
color: #333;
letter-spacing: 2px;
margin-top: 20px;
}
#tunein {
margin-top: 40px;
font-family: 'Bebas Neue', 'Impact', sans-serif;
font-size: 30px;
letter-spacing: 8px;
color: #fff;
background: #0daa1e;
border: none;
padding: 16px 60px;
cursor: pointer;
transition: all 0.2s;
animation: btn-pulse 2s ease-in-out infinite;
}
#tunein:hover {
background: #10e020;
transform: scale(1.05);
}
@keyframes btn-pulse {
0%, 100% { box-shadow: 0 0 20px rgba(16, 224, 32, 0.4); }
50% { box-shadow: 0 0 50px rgba(16, 224, 32, 0.8), 0 0 100px rgba(16, 224, 32, 0.3); }
}
/* ── radio player ── */
#radio {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: 100%;
max-width: 600px;
padding: 20px;
}
#radio-header {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
font-size: 20px;
letter-spacing: 6px;
color: #333;
white-space: nowrap;
}
#now-artist {
font-size: clamp(28px, 10vw, 64px);
color: #fff;
line-height: 1.1;
text-transform: uppercase;
letter-spacing: 2px;
text-shadow: 0 0 30px rgba(16, 224, 32, 0.25);
overflow-wrap: break-word;
word-break: normal;
max-width: 100%;
position: relative;
display: inline-block;
}
#now-artist::before,
#now-artist::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
overflow: hidden;
}
#now-artist::before {
color: #fff;
text-shadow: -2px 0 #10e020;
animation: glitch-top 1.5s infinite linear alternate-reverse;
clip-path: inset(0 0 55% 0);
}
#now-artist::after {
color: #fff;
text-shadow: 2px 0 #0daa1e;
animation: glitch-bottom 1.3s infinite linear alternate-reverse;
clip-path: inset(50% 0 0 0);
}
#now-track {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 14px;
color: #666;
margin-top: 4px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#now-genre {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 11px;
color: #0daa1e;
letter-spacing: 3px;
text-transform: uppercase;
margin-top: 6px;
}
/* ── progress ── */
#progress-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin-top: 24px;
}
#time-elapsed, #time-total {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 11px;
color: #444;
min-width: 36px;
}
#time-elapsed {
text-align: right;
}
#time-total {
text-align: left;
}
#progress-wrap {
flex: 1;
height: 3px;
background: #1a1a1a;
overflow: hidden;
}
#progress-bar {
height: 100%;
width: 0%;
background: #0daa1e;
transition: width 0.5s linear;
}
/* ── votes ── */
#votes {
display: flex;
gap: 20px;
margin-top: 24px;
}
.vote-btn {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid #1a1a1a;
color: #444;
padding: 8px 16px;
cursor: pointer;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
border-radius: 4px;
transition: all 0.15s;
}
.vote-btn:hover {
border-color: #333;
color: #888;
}
.vote-btn.voted {
border-color: #0daa1e;
color: #10e020;
}
#vote-skip.voted {
border-color: #c44;
color: #e55;
}
.vote-emoji {
font-size: 16px;
line-height: 1;
}
/* ── car mode ── */
#car-btn.active {
border-color: #0daa1e;
color: #10e020;
}
body.car-mode {
background: #000 !important;
background-image: none !important;
}
body.car-mode.shake {
animation: none !important;
}
body.car-mode #bg-video,
body.car-mode #bg-video2 {
display: none !important;
}
body.car-mode #radio-header,
body.car-mode #now-genre,
body.car-mode #votes,
body.car-mode #listener-count-radio {
display: none !important;
}
body.car-mode #now-artist {
font-size: clamp(36px, 14vw, 100px);
overflow-wrap: normal;
}
body.car-mode #now-artist,
body.car-mode #now-artist::before,
body.car-mode #now-artist::after {
animation: none !important;
}
body.car-mode #now-artist::before,
body.car-mode #now-artist::after {
display: none;
}
body.car-mode #now-track {
font-size: clamp(16px, 5vw, 28px);
color: #888;
}
/* ── buffering ── */
#buffering {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 11px;
color: #666;
letter-spacing: 3px;
z-index: 50;
pointer-events: none;
}
+518
View File
@@ -0,0 +1,518 @@
const audio = document.getElementById('audio');
const splash = document.getElementById('splash');
const radioEl = document.getElementById('radio');
const artistEl = document.getElementById('now-artist');
const trackEl = document.getElementById('now-track');
const genreEl = document.getElementById('now-genre');
const bgVideo = document.getElementById('bg-video');
const bgVideo2 = document.getElementById('bg-video2');
const progressB = document.getElementById('progress-bar');
const elapsedEl = document.getElementById('time-elapsed');
const totalEl = document.getElementById('time-total');
const volBtn = document.getElementById('vol-btn');
const volDrop = document.getElementById('vol-dropdown');
const volEl = document.getElementById('vol');
const tuneinBtn = document.getElementById('tunein');
const skipBtn = document.getElementById('skip-btn');
const thpsBtn = document.getElementById('thps-btn');
const hxcBtn = document.getElementById('hxc-btn');
const voteUpBtn = document.getElementById('vote-up');
const voteDownBtn = document.getElementById('vote-down');
const countUpEl = document.getElementById('count-up');
const countDownEl = document.getElementById('count-down');
const voteSkipBtn = document.getElementById('vote-skip');
const skipCountEl = document.getElementById('skip-vote-count');
const listenerEl = document.getElementById('listener-count');
const listenerEl2 = document.getElementById('listener-count-radio');
let currentStartedAt = null;
let syncElapsed = 0;
let syncLocalTime = 0;
let songDuration = 0;
let pollTimer = null;
let firstSong = true;
let currentSongKey = null;
let myVote = null;
let audioCtx = null;
let gainNode = null;
let hasVotedSkip = false;
const sessionId = Math.random().toString(36).slice(2);
const reconnectEl = document.getElementById('reconnect');
const carBtn = document.getElementById('car-btn');
const bufferingEl = document.getElementById('buffering');
let carMode = localStorage.getItem('acid_radio_car') === 'on';
let disconnected = false;
let preloadedUrl = null;
let preloadedKey = null;
let activeBlobUrl = null;
audio.volume = 0.8;
audio.addEventListener('waiting', () => bufferingEl.classList.remove('hidden'));
audio.addEventListener('playing', () => bufferingEl.classList.add('hidden'));
audio.addEventListener('canplay', () => bufferingEl.classList.add('hidden'));
function initAudioBoost() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const source = audioCtx.createMediaElementSource(audio);
gainNode = audioCtx.createGain();
source.connect(gainNode);
gainNode.connect(audioCtx.destination);
gainNode.gain.value = volEl.value / 100;
audio.volume = 1.0;
}
function getClientId() {
let id = localStorage.getItem('acid_radio_id');
if (!id) {
try { id = crypto.randomUUID(); } catch (e) {
id = Array.from(crypto.getRandomValues(new Uint8Array(16)),
b => b.toString(16).padStart(2, '0')).join('');
}
localStorage.setItem('acid_radio_id', id);
}
return id;
}
const clientId = getClientId();
function fmt(s) {
s = Math.max(0, Math.floor(s));
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
async function fetchNow() {
const r = await fetch('/api/radio/now?sid=' + sessionId);
return await r.json();
}
async function fetchListeners() {
try {
const r = await fetch('/api/radio/listeners');
const data = await r.json();
const txt = data.count + ' listening';
listenerEl.textContent = txt;
listenerEl2.textContent = txt;
} catch (e) {}
}
function notify(artist, track) {
try {
if (Notification.permission !== 'granted') return;
new Notification('ACID RADIO', {
body: artist + ' \u2014 ' + track,
silent: true,
});
} catch (e) {}
}
function preloadNext(folder, file) {
const key = folder + '/' + file;
if (key === preloadedKey) return;
if (preloadedUrl) URL.revokeObjectURL(preloadedUrl);
preloadedUrl = null;
preloadedKey = key;
fetch('/music/' + encodeURIComponent(folder) + '/' + encodeURIComponent(file))
.then(r => r.blob())
.then(blob => {
if (preloadedKey !== key) return;
preloadedUrl = URL.createObjectURL(blob);
})
.catch(() => { if (preloadedKey === key) preloadedKey = null; });
}
function updateMediaSession(artist, track, genre) {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: track,
artist: artist,
album: genre || 'ACID RADIO',
});
}
async function fetchVotes() {
if (!currentSongKey) return;
try {
const r = await fetch('/api/radio/votes?song=' + encodeURIComponent(currentSongKey) + '&client=' + encodeURIComponent(clientId));
const data = await r.json();
countUpEl.textContent = data.up;
countDownEl.textContent = data.down;
myVote = data.my_vote;
voteUpBtn.classList.toggle('voted', myVote === 'up');
voteDownBtn.classList.toggle('voted', myVote === 'down');
} catch (e) {}
}
async function castVote(direction) {
if (!currentSongKey) return;
const newVote = (myVote === direction) ? null : direction;
try {
const r = await fetch('/api/radio/vote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
song: currentSongKey,
client: clientId,
vote: newVote,
}),
});
const data = await r.json();
countUpEl.textContent = data.up;
countDownEl.textContent = data.down;
myVote = newVote;
voteUpBtn.classList.toggle('voted', myVote === 'up');
voteDownBtn.classList.toggle('voted', myVote === 'down');
} catch (e) {}
}
async function fetchSkipInfo() {
if (!currentStartedAt) return;
try {
const r = await fetch('/api/radio/skip-info?ts=' + currentStartedAt + '&client=' + encodeURIComponent(clientId));
const data = await r.json();
skipCountEl.textContent = data.votes;
hasVotedSkip = data.voted;
voteSkipBtn.classList.toggle('voted', data.voted);
voteSkipBtn.classList.toggle('hidden', data.remaining <= 0);
} catch (e) {}
}
async function castSkipVote() {
if (!currentStartedAt || hasVotedSkip) return;
try {
const r = await fetch('/api/radio/vote-skip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ts: currentStartedAt, client: clientId }),
});
const data = await r.json();
skipCountEl.textContent = data.votes;
hasVotedSkip = true;
voteSkipBtn.classList.add('voted');
voteSkipBtn.classList.toggle('hidden', data.remaining <= 0);
if (data.skipped) {
await syncSong();
}
} catch (e) {}
}
async function syncSong() {
let state;
try {
state = await fetchNow();
} catch (e) {
if (!disconnected) {
disconnected = true;
reconnectEl.classList.remove('hidden');
}
return;
}
if (!state) return;
if (disconnected) {
disconnected = false;
reconnectEl.classList.add('hidden');
}
const songChanged = currentStartedAt !== null && state.started_at !== currentStartedAt;
currentStartedAt = state.started_at;
syncElapsed = state.server_time - state.started_at;
syncLocalTime = Date.now() / 1000;
songDuration = state.duration;
artistEl.textContent = state.artist;
artistEl.setAttribute('data-text', state.artist);
trackEl.textContent = state.track;
genreEl.textContent = state.genre || '';
updateMediaSession(state.artist, state.track, state.genre);
const artistBgs = {
'Tony Hawks': [
'/images/tonyhawks/background.gif',
'/images/tonyhawks/2.gif',
'/images/tonyhawks/3.gif',
'/images/tonyhawks/4.gif',
'/images/tonyhawks/5.gif',
],
};
const genreBgs = {
'indie': [
'/images/indie/arnold.gif',
'/images/indie/bart.gif',
'/images/indie/drum.gif',
],
};
if (window._bgInterval) {
clearInterval(window._bgInterval);
window._bgInterval = null;
}
bgVideo.ontimeupdate = null;
bgVideo.onloadedmetadata = null;
bgVideo.onended = null;
bgVideo2.onended = null;
bgVideo2.classList.remove('active');
bgVideo2.pause();
bgVideo2.removeAttribute('src');
const genre = (state.genre || '').toLowerCase();
const isHardcore = genre === 'hardcore';
const isPostHardcore = genre === 'post hardcore' || genre === 'post-hardcore';
const isFolkPunk = genre === 'folk punk' || genre === 'folk-punk';
const isPunk = genre === 'punk';
const bgs = artistBgs[state.folder];
if (isHardcore) {
document.body.style.backgroundImage = '';
document.body.classList.remove('artist-bg');
if (bgVideo.getAttribute('src') !== '/video/crowdkill.mp4') {
bgVideo.src = '/video/crowdkill.mp4';
}
bgVideo.onended = () => { bgVideo.currentTime = 0; bgVideo.play(); };
bgVideo.classList.add('active');
bgVideo.play();
} else if (isPostHardcore) {
document.body.style.backgroundImage = '';
document.body.classList.remove('artist-bg');
if (bgVideo.getAttribute('src') !== '/video/posthardcore.mp4') {
bgVideo.src = '/video/posthardcore.mp4';
}
bgVideo.onended = () => { bgVideo.currentTime = 0; bgVideo.play(); };
bgVideo.classList.add('active');
bgVideo.play();
} else if (isFolkPunk) {
document.body.style.backgroundImage = '';
document.body.classList.remove('artist-bg');
if (bgVideo.getAttribute('src') !== '/video/folkpunk.mp4') {
bgVideo.src = '/video/folkpunk.mp4';
}
bgVideo.onended = () => { bgVideo.currentTime = 0; bgVideo.play(); };
bgVideo.classList.add('active');
bgVideo.play();
} else if (isPunk) {
document.body.style.backgroundImage = '';
document.body.classList.remove('artist-bg');
const jayClips = ['/video/jay.mp4', '/video/jay2.mp4'];
const vids = [bgVideo, bgVideo2];
let cur = 0;
vids[0].src = jayClips[0];
vids[1].src = jayClips[1];
vids[1].load();
function jaySwap() {
const next = 1 - cur;
vids[next].classList.add('active');
vids[next].play();
vids[cur].classList.remove('active');
cur = next;
const preloadIdx = 1 - cur;
vids[preloadIdx].src = jayClips[preloadIdx];
vids[preloadIdx].load();
}
vids[0].onended = jaySwap;
vids[1].onended = jaySwap;
vids[0].classList.add('active');
vids[0].play();
} else if (bgs) {
bgVideo.classList.remove('active');
bgVideo.pause();
let idx = 0;
document.body.style.backgroundImage = 'url(' + bgs[idx] + ')';
document.body.classList.add('artist-bg');
window._bgInterval = setInterval(() => {
idx = (idx + 1) % bgs.length;
document.body.style.backgroundImage = 'url(' + bgs[idx] + ')';
}, 5000);
} else if (genreBgs[genre]) {
bgVideo.classList.remove('active');
bgVideo.pause();
let idx = 0;
const gBgs = genreBgs[genre];
document.body.style.backgroundImage = 'url(' + gBgs[idx] + ')';
document.body.classList.add('artist-bg');
window._bgInterval = setInterval(() => {
idx = (idx + 1) % gBgs.length;
document.body.style.backgroundImage = 'url(' + gBgs[idx] + ')';
}, 5000);
} else {
bgVideo.classList.remove('active');
bgVideo.pause();
document.body.style.backgroundImage = '';
document.body.classList.remove('artist-bg');
}
currentSongKey = state.folder + '/' + state.file;
myVote = null;
hasVotedSkip = false;
voteUpBtn.classList.remove('voted');
voteDownBtn.classList.remove('voted');
voteSkipBtn.classList.remove('voted');
skipCountEl.textContent = '0';
fetchVotes();
fetchSkipInfo();
if (activeBlobUrl) {
URL.revokeObjectURL(activeBlobUrl);
activeBlobUrl = null;
}
const songPath = state.folder + '/' + state.file;
if (preloadedKey === songPath && preloadedUrl) {
audio.src = preloadedUrl;
activeBlobUrl = preloadedUrl;
preloadedUrl = null;
preloadedKey = null;
} else {
audio.src = '/music/' + encodeURIComponent(state.folder) + '/' + encodeURIComponent(state.file);
}
if (state.next) preloadNext(state.next.folder, state.next.file);
audio.onloadedmetadata = () => {
const nowLocal = Date.now() / 1000;
const elapsed = syncElapsed + (nowLocal - syncLocalTime);
audio.currentTime = Math.min(elapsed, audio.duration - 0.5);
audio.play().catch(() => {});
};
audio.onerror = () => {};
if (songChanged || firstSong) {
firstSong = false;
notify(state.artist, state.track);
}
}
async function checkForChange() {
try {
const state = await fetchNow();
if (!state) return;
if (disconnected) {
disconnected = false;
reconnectEl.classList.add('hidden');
}
if (state.started_at !== currentStartedAt) {
await syncSong();
}
} catch (e) {
if (!disconnected) {
disconnected = true;
reconnectEl.classList.remove('hidden');
}
}
}
function updateProgress() {
if (!songDuration) {
requestAnimationFrame(updateProgress);
return;
}
const elapsed = syncElapsed + (Date.now() / 1000 - syncLocalTime);
const pct = Math.min((elapsed / songDuration) * 100, 100);
progressB.style.width = pct + '%';
elapsedEl.textContent = fmt(elapsed);
totalEl.textContent = fmt(songDuration);
requestAnimationFrame(updateProgress);
}
tuneinBtn.onclick = async () => {
try {
if (Notification.permission === 'default')
await Notification.requestPermission();
} catch (e) {}
initAudioBoost();
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => audio.play());
navigator.mediaSession.setActionHandler('pause', () => audio.pause());
}
splash.classList.add('hidden');
radioEl.classList.remove('hidden');
await syncSong();
pollTimer = setInterval(checkForChange, 3000);
setInterval(() => { fetchVotes(); fetchSkipInfo(); }, 10000);
requestAnimationFrame(updateProgress);
};
skipBtn.onclick = async () => {
try { await fetch('/api/radio/skip'); } catch (e) {}
try { await syncSong(); } catch (e) {}
};
thpsBtn.onclick = async () => {
try { await fetch('/api/radio/skip-to?artist=' + encodeURIComponent('Tony Hawks')); } catch (e) {}
try { await syncSong(); } catch (e) {}
};
hxcBtn.onclick = async () => {
try { await fetch('/api/radio/skip-to-genre?genre=hardcore'); } catch (e) {}
try { await syncSong(); } catch (e) {}
};
volBtn.onclick = e => {
e.stopPropagation();
volDrop.classList.toggle('hidden');
};
document.addEventListener('click', e => {
if (!volDrop.contains(e.target) && e.target !== volBtn && !volBtn.contains(e.target)) {
volDrop.classList.add('hidden');
}
});
volEl.oninput = () => {
if (gainNode) {
gainNode.gain.value = volEl.value / 100;
} else {
audio.volume = Math.min(volEl.value / 100, 1.0);
}
};
voteUpBtn.onclick = () => castVote('up');
voteDownBtn.onclick = () => castVote('down');
voteSkipBtn.onclick = () => castSkipVote();
carBtn.onclick = () => {
carMode = !carMode;
localStorage.setItem('acid_radio_car', carMode ? 'on' : 'off');
document.body.classList.toggle('car-mode', carMode);
carBtn.classList.toggle('active', carMode);
};
if (carMode) {
document.body.classList.add('car-mode');
carBtn.classList.add('active');
}
function scheduleShake() {
const delay = 1500 + Math.random() * 4000;
setTimeout(() => {
document.body.classList.add('shake');
setTimeout(() => document.body.classList.remove('shake'), 150);
scheduleShake();
}, delay);
}
scheduleShake();
fetchListeners();
setInterval(fetchListeners, 10000);
fetch('/api/debug').then(r => r.json()).then(data => {
if (data.debug) {
document.querySelectorAll('.debug-btn').forEach(el => el.classList.remove('hidden'));
}
}).catch(() => {});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
+179
View File
@@ -0,0 +1,179 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #0a0a0a;
color: #c8c8c8;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
::selection {
background: #333;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #222;
border-radius: 3px;
}
#list {
flex: 1;
overflow-y: auto;
padding: 12px 0;
}
.artist-header {
display: flex;
justify-content: space-between;
padding: 5px 16px;
cursor: pointer;
white-space: nowrap;
transition: background 0.1s;
user-select: none;
}
.artist-header:hover {
background: #141414;
}
.artist-header.open {
color: #fff;
background: #131313;
}
.artist-name {
overflow: hidden;
text-overflow: ellipsis;
}
.artist-count {
color: #444;
margin-left: 20px;
flex-shrink: 0;
}
.track-list {
display: none;
}
.track-list.open {
display: block;
}
.track {
padding: 4px 16px 4px 32px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #777;
transition: background 0.1s;
}
.track:hover {
background: #141414;
color: #aaa;
}
.track.active {
color: #fff;
background: #161616;
}
#player {
border-top: 1px solid #1a1a1a;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 14px;
min-height: 52px;
background: #0d0d0d;
}
#now {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: #888;
font-size: 12px;
}
#now span {
color: #c8c8c8;
}
#controls {
display: flex;
align-items: center;
gap: 10px;
}
#controls button {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 16px;
padding: 2px 4px;
font-family: inherit;
}
#controls button:hover {
color: #fff;
}
#time {
color: #555;
font-size: 11px;
min-width: 90px;
text-align: center;
}
#seek {
flex: 2;
max-width: 400px;
}
#vol {
width: 80px;
}
input[type=range] {
-webkit-appearance: none;
appearance: none;
background: #1a1a1a;
height: 3px;
border-radius: 2px;
outline: none;
cursor: pointer;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 10px;
height: 10px;
background: #555;
border-radius: 50%;
cursor: pointer;
}
input[type=range]::-webkit-slider-thumb:hover {
background: #888;
}
+31
View File
@@ -0,0 +1,31 @@
const CACHE = 'acid-radio-v1';
const ASSETS = ['/', '/radio.css', '/radio.js'];
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
self.skipWaiting();
});
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', e => {
const url = new URL(e.request.url);
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/music/') ||
url.pathname.startsWith('/video/') || url.pathname.startsWith('/images/')) {
return;
}
e.respondWith(
fetch(e.request).then(r => {
const clone = r.clone();
caches.open(CACHE).then(c => c.put(e.request, clone));
return r;
}).catch(() => caches.match(e.request))
);
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.