initial commit
@@ -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> — ' + track.replace(/\.[^.]+$/, '');
|
||||
playBtn.innerHTML = '▮▮';
|
||||
|
||||
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 = '▮▮';
|
||||
} else {
|
||||
audio.pause();
|
||||
playBtn.innerHTML = '▶';
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
};
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 810 KiB |
@@ -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 |
|
After Width: | Height: | Size: 4.5 MiB |
|
After Width: | Height: | Size: 982 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 6.0 MiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 14 MiB |
@@ -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>
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
});
|
||||