Files
live-radio/gateway/winamp.html
T

485 lines
18 KiB
HTML
Raw Normal View History

<!--
title: Live Ozan Radio — Winamp
date: 2026-06-07
author: Ozan
source: live-radio
description: Pure HTML Winamp-style player for saved Lyria tracks. Local /winamp streams via API; tinqs.com uses Git LFS media URLs.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live Ozan Radio — Winamp</title>
<style>
:root {
--wa-bg: #232323;
--wa-panel: #3a3a3a;
--wa-bevel-light: #5a5a5a;
--wa-bevel-dark: #1a1a1a;
--wa-lcd-bg: #0a1a0a;
--wa-lcd-text: #33ff66;
--wa-accent: #ffcc00;
--wa-text: #e8e8e8;
--wa-muted: #9a9a9a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
background: #111;
font-family: "Arial", "Segoe UI", sans-serif;
font-size: 11px;
color: var(--wa-text);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background-image: radial-gradient(ellipse at 50% 0%, #2a2a3a, #111 70%);
}
.wa-wrap { width: min(420px, 96vw); }
.wa-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(180deg, #4a6a8a 0%, #2a4a6a 50%, #1a3a5a 100%);
border: 1px solid #000;
border-radius: 3px 3px 0 0;
padding: 2px 4px;
font-weight: bold;
font-size: 10px;
letter-spacing: 0.02em;
color: #fff;
text-shadow: 1px 1px 0 #000;
}
.wa-titlebar .btns { display: flex; gap: 2px; }
.wa-titlebar .btns span {
width: 9px; height: 9px;
border: 1px solid #000;
background: linear-gradient(180deg, #ccc, #888);
font-size: 7px;
line-height: 7px;
text-align: center;
cursor: default;
}
.wa-main {
background: var(--wa-bg);
border: 2px solid var(--wa-bevel-dark);
border-top: none;
box-shadow: inset 1px 1px 0 var(--wa-bevel-light);
padding: 6px;
}
.wa-lcd {
background: var(--wa-lcd-bg);
border: 2px inset var(--wa-bevel-dark);
padding: 6px 8px;
margin-bottom: 6px;
min-height: 52px;
}
.wa-lcd .scroll {
color: var(--wa-lcd-text);
font-family: "Courier New", monospace;
font-size: 12px;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-shadow: 0 0 6px rgba(51,255,102,0.5);
}
.wa-lcd .scroll.anim { animation: scroll 12s linear infinite; }
@keyframes scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.wa-lcd .meta {
color: #228b22;
font-family: "Courier New", monospace;
font-size: 9px;
margin-top: 4px;
opacity: 0.85;
}
.wa-eq {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 2px;
height: 28px;
margin-bottom: 6px;
background: #1a1a1a;
border: 1px inset var(--wa-bevel-dark);
padding: 3px 6px;
}
.wa-eq .bar {
width: 4px;
background: linear-gradient(to top, #0a0, #ff0, #f80);
border-radius: 1px;
animation: eq 0.5s ease-in-out infinite alternate;
}
.wa-eq.paused .bar { animation-play-state: paused; opacity: 0.25; height: 4px !important; }
@keyframes eq {
from { transform: scaleY(0.3); }
to { transform: scaleY(1); }
}
.wa-controls {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
margin-bottom: 6px;
}
.wa-btn {
background: linear-gradient(180deg, #666 0%, #444 50%, #333 100%);
border: 1px solid #000;
border-radius: 2px;
color: #fff;
font-size: 10px;
font-weight: bold;
padding: 5px 2px;
cursor: pointer;
text-shadow: 1px 1px 0 #000;
box-shadow: inset 1px 1px 0 var(--wa-bevel-light);
}
.wa-btn:hover { filter: brightness(1.15); }
.wa-btn:active { filter: brightness(0.9); box-shadow: inset 1px 1px 3px #000; }
.wa-btn.active { background: linear-gradient(180deg, #6a8a4a, #4a6a2a); }
.wa-vol {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
font-size: 9px;
color: var(--wa-muted);
}
.wa-vol input[type=range] { flex: 1; accent-color: var(--wa-accent); }
.wa-plist {
background: var(--wa-panel);
border: 2px inset var(--wa-bevel-dark);
max-height: 180px;
overflow-y: auto;
margin-top: 4px;
}
.wa-plist-head {
background: linear-gradient(180deg, #555, #333);
padding: 3px 6px;
font-weight: bold;
font-size: 9px;
border-bottom: 1px solid #000;
position: sticky;
top: 0;
}
.wa-track {
padding: 4px 6px;
border-bottom: 1px solid #2a2a2a;
cursor: pointer;
display: flex;
gap: 6px;
align-items: baseline;
}
.wa-track:hover { background: #4a4a4a; }
.wa-track.playing { background: #3a5a3a; color: #cfc; }
.wa-track .num { color: var(--wa-muted); width: 1.5em; font-size: 9px; }
.wa-track .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wa-status {
font-size: 9px;
color: var(--wa-muted);
margin-top: 6px;
text-align: center;
}
.wa-links {
margin-top: 10px;
text-align: center;
font-size: 9px;
color: #666;
}
.wa-links a { color: var(--wa-accent); text-decoration: none; }
.wa-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
display: none;
align-items: center;
justify-content: center;
z-index: 20;
cursor: pointer;
}
.wa-overlay.show { display: flex; }
.wa-overlay button {
background: linear-gradient(180deg, #6a8a4a, #4a6a2a);
border: 2px outset #8a8;
color: #fff;
font-weight: bold;
padding: 12px 20px;
cursor: pointer;
font-size: 12px;
}
audio { display: none; }
</style>
</head>
<body>
<div class="wa-overlay" id="overlay">
<button type="button" id="startBtn">▶ Click to start Winamp Radio</button>
</div>
<div class="wa-wrap">
<div class="wa-titlebar">
<span>WINAMP — Live Ozan Radio</span>
<div class="btns"><span>_</span><span></span><span>×</span></div>
</div>
<div class="wa-main">
<div class="wa-lcd">
<div class="scroll" id="lcdTitle">*** LIVE OZAN RADIO ***</div>
<div class="meta" id="lcdMeta">techno-ethnic · AI-composed · no catalog</div>
</div>
<div class="wa-eq paused" id="eq">
<div class="bar" style="height:8px;animation-delay:0s"></div>
<div class="bar" style="height:14px;animation-delay:0.1s"></div>
<div class="bar" style="height:20px;animation-delay:0.05s"></div>
<div class="bar" style="height:12px;animation-delay:0.15s"></div>
<div class="bar" style="height:18px;animation-delay:0.08s"></div>
<div class="bar" style="height:10px;animation-delay:0.12s"></div>
<div class="bar" style="height:22px;animation-delay:0.03s"></div>
<div class="bar" style="height:16px;animation-delay:0.18s"></div>
<div class="bar" style="height:11px;animation-delay:0.07s"></div>
<div class="bar" style="height:19px;animation-delay:0.11s"></div>
<div class="bar" style="height:13px;animation-delay:0.14s"></div>
<div class="bar" style="height:17px;animation-delay:0.06s"></div>
</div>
<div class="wa-controls">
<button class="wa-btn" id="btnPrev" title="Previous">|&lt;&lt;</button>
<button class="wa-btn" id="btnPlay" title="Play/Pause"></button>
<button class="wa-btn" id="btnStop" title="Stop"></button>
<button class="wa-btn" id="btnNext" title="Next">&gt;&gt;|</button>
<button class="wa-btn" id="btnShuffle" title="Shuffle">SHF</button>
</div>
<div class="wa-vol">
<span>VOL</span>
<input type="range" id="volume" min="0" max="100" value="85">
</div>
<div class="wa-plist">
<div class="wa-plist-head">PLAYLIST — saved songs</div>
<div id="plist"></div>
</div>
<div class="wa-status" id="status">Loading playlist…</div>
<div class="wa-links">
<a href="index.html">Public radio</a> ·
<a href="/player">Full dashboard</a> ·
<a href="https://tinqs.com/tinqs/live-radio" target="_blank" rel="noopener">Git Studio</a>
</div>
</div>
</div>
<audio id="player" preload="auto" playsinline></audio>
<script>
/*__PLAYLIST__*/
{"station": "Live Ozan Radio", "tagline": "Techno-ethnic AI radio — no catalog tracks.", "media_base": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/", "share_url": "https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/index.html", "winamp_url": "https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/winamp.html", "updated": "2026-06-07T15:38:41.264287+00:00", "tracks": [{"id": "82595273", "title": "Caravan of the Blue Hour", "mood": "hypnotic, warm, spacious", "dj_line": "Late-night dub caravan from the Sahel — let the bass carry you across the dunes.", "file": "82595273_Caravan_of_the_Blue_Hour.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/82595273_Caravan_of_the_Blue_Hour.mp3", "rating": "keeper", "shuffle_weight": 1.1, "genres": [], "categories": []}, {"id": "eeebb429", "title": "Desert Mirage", "mood": "hypnotic, warm, late-night desert dub", "dj_line": "Sending you a dust-kissed groove from the edge of the dunes — let the delays carry you through the night.", "file": "eeebb429_Desert_Mirage.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/eeebb429_Desert_Mirage.mp3", "rating": "keeper", "shuffle_weight": 1.0, "genres": [], "categories": []}, {"id": "1c1d7b8a", "title": "Sahara's Saz", "mood": "Hypnotic desert dub with Anadolu warmth", "dj_line": "Blowing sand and saz strings — a slow burn across the dunes, right here on Live Ozan.", "file": "1c1d7b8a_Sahara_s_Saz.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/1c1d7b8a_Sahara_s_Saz.mp3", "rating": "love", "shuffle_weight": 1.5, "genres": [], "categories": []}, {"id": "aee4994a", "title": "Nomad's Saz", "mood": "hypnotic desert dub with Turkish soul", "dj_line": "From the Sahara to Anatolia — here's a caravan of warm analog dub.", "file": "aee4994a_Nomad_s_Saz.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/aee4994a_Nomad_s_Saz.mp3", "rating": "keeper", "shuffle_weight": 1.0, "genres": [], "categories": []}, {"id": "839aa313", "title": "Caravan of the Night", "mood": "hypnotic desert dub, late-night caravan, warm and spacious", "dj_line": "From the Sahel to the Anatolian plateau, let the caravan carry you through the night — this one's for the wanderers.", "file": "839aa313_Caravan_of_the_Night.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/839aa313_Caravan_of_the_Night.mp3", "rating": "keeper", "shuffle_weight": 0.75, "genres": [], "categories": []}, {"id": "ce7ae31e", "title": "Chac's Dub", "mood": "ceremonial, deep, hypnotic", "dj_line": "From the temple steps to the dance floor — here's a Mesoamerican ceremonial dub built for the blue hour.", "file": "ce7ae31e_Chac_s_Dub.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/ce7ae31e_Chac_s_Dub.mp3", "rating": "keeper", "shuffle_weight": 1.25, "genres": ["ceremonial-dub", "mesoamerican-electronica", "world-dub"], "categories": ["ceremonial-world", "dub-space", "vocal-ethnic"]}, {"id": "71cfdfea", "title": "Frostbite Dub", "mood": "cold cinematic gothic with sub bass warmth", "dj_line": "From the frozen steppe to the warm sub — Nordic ether dub for the only lovers left alive.", "file": "71cfdfea_Frostbite_Dub.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/71cfdfea_Frostbite_Dub.mp3", "rating": "keeper", "shuffle_weight": 1.25, "genres": ["gregorian-ether", "nordic-dub", "cinematic-gothic"], "categories": ["cinematic-gothic", "vocal-ethnic", "dub-space"]}, {"id": "74646b3e", "title": "Echoes of the Sahel", "mood": "warm, dusty, spacious, hypnotic", "dj_line": "From the desert edge to the dub chamber — here's a late-night caravan drenched in spring reverb.", "file": "74646b3e_Echoes_of_the_Sahel.mp3", "url": "https://tinqs.com/tinqs/live-radio/media/branch/main/songs/74646b3e_Echoes_of_the_Sahel.mp3", "rating": "unrated", "shuffle_weight": 1.0, "genres": [], "categories": []}]}
/*__PLAYLIST__*/
const player = document.getElementById('player');
const lcdTitle = document.getElementById('lcdTitle');
const lcdMeta = document.getElementById('lcdMeta');
const eq = document.getElementById('eq');
const plistEl = document.getElementById('plist');
const statusEl = document.getElementById('status');
const overlay = document.getElementById('overlay');
const startBtn = document.getElementById('startBtn');
const btnPlay = document.getElementById('btnPlay');
const btnStop = document.getElementById('btnStop');
const btnPrev = document.getElementById('btnPrev');
const btnNext = document.getElementById('btnNext');
const btnShuffle = document.getElementById('btnShuffle');
const volume = document.getElementById('volume');
let playlist = null;
let tracks = [];
let queue = [];
let index = 0;
let shuffleOn = true;
let started = false;
const isLocal = location.hostname === '127.0.0.1' || location.hostname === 'localhost';
function parseEmbeddedPlaylist() {
const scripts = document.getElementsByTagName('script');
for (const s of scripts) {
const text = s.textContent || '';
const m = text.match(/\/\*__PLAYLIST__\*\/\s*([\s\S]*?)\s*\/\*__PLAYLIST__\*\//);
if (m) {
try { return JSON.parse(m[1]); } catch (_) {}
}
}
return null;
}
function trackUrl(t) {
if (isLocal && t.file) return '/stream/' + encodeURIComponent(t.file);
return t.url || ((playlist && playlist.media_base) || '') + (t.file || '');
}
function shuffle(arr) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function rebuildQueue() {
queue = shuffleOn ? shuffle(tracks) : tracks.slice();
if (!queue.length) return;
const cur = tracks[index];
if (cur) {
const qi = queue.findIndex(t => t.id === cur.id);
index = qi >= 0 ? qi : 0;
} else {
index = 0;
}
}
function setLcd(t) {
const title = (t && t.title) ? t.title : 'No track';
const dup = title.length > 28 ? title + ' · ' + title : title;
lcdTitle.textContent = dup;
lcdTitle.classList.toggle('anim', title.length > 28);
lcdMeta.textContent = t
? (t.mood || t.dj_line || playlist.tagline || '')
: (playlist && playlist.tagline) || '';
document.title = (playlist && playlist.station) || 'Winamp — Ozan Radio';
}
function renderPlaylist() {
plistEl.innerHTML = '';
tracks.forEach((t, i) => {
const row = document.createElement('div');
row.className = 'wa-track' + (queue[index] && queue[index].id === t.id ? ' playing' : '');
row.innerHTML = '<span class="num">' + (i + 1) + '.</span><span class="name"></span>';
row.querySelector('.name').textContent = t.title || t.file || 'Untitled';
row.addEventListener('click', () => {
const qi = queue.findIndex(x => x.id === t.id);
if (qi >= 0) index = qi;
else { queue = tracks.slice(); index = i; shuffleOn = false; btnShuffle.classList.remove('active'); }
playCurrent();
});
plistEl.appendChild(row);
});
}
function playCurrent() {
if (!queue.length) {
setLcd(null);
statusEl.textContent = 'No tracks — generate locally, then export-web';
return;
}
const t = queue[index];
setLcd(t);
const url = trackUrl(t);
if (player.src !== location.origin + url && player.src !== url) {
player.src = url;
}
renderPlaylist();
statusEl.textContent = 'Playing ' + (index + 1) + '/' + queue.length + (isLocal ? ' · local stream' : ' · tinqs LFS');
return player.play();
}
function nextTrack() {
if (!queue.length) return;
index = (index + 1) % queue.length;
playCurrent().catch(showOverlay);
}
function prevTrack() {
if (!queue.length) return;
if (player.currentTime > 3) {
player.currentTime = 0;
return;
}
index = (index - 1 + queue.length) % queue.length;
playCurrent().catch(showOverlay);
}
function togglePlay() {
if (player.paused) playCurrent().catch(showOverlay);
else player.pause();
}
function stopTrack() {
player.pause();
player.currentTime = 0;
eq.classList.add('paused');
btnPlay.textContent = '▶';
statusEl.textContent = 'Stopped';
}
function showOverlay() {
overlay.classList.add('show');
statusEl.textContent = 'Click to start (browser autoplay policy)';
}
function hideOverlay() {
overlay.classList.remove('show');
}
function startRadio() {
if (started && !player.paused) return;
started = true;
hideOverlay();
playCurrent().catch(showOverlay);
}
async function init() {
playlist = parseEmbeddedPlaylist();
if (!playlist || !playlist.tracks || !playlist.tracks.length) {
try {
const res = await fetch('playlist.json');
if (res.ok) playlist = await res.json();
} catch (_) {}
}
tracks = (playlist && playlist.tracks) || [];
if (!tracks.length) {
setLcd(null);
statusEl.textContent = 'Library empty — python -m ozan_radio export-web';
return;
}
rebuildQueue();
btnShuffle.classList.add('active');
renderPlaylist();
player.volume = volume.value / 100;
volume.addEventListener('input', () => { player.volume = volume.value / 100; });
player.addEventListener('ended', nextTrack);
player.addEventListener('play', () => { eq.classList.remove('paused'); btnPlay.textContent = '❚❚'; });
player.addEventListener('pause', () => { eq.classList.add('paused'); btnPlay.textContent = '▶'; });
btnPlay.addEventListener('click', togglePlay);
btnStop.addEventListener('click', stopTrack);
btnNext.addEventListener('click', nextTrack);
btnPrev.addEventListener('click', prevTrack);
btnShuffle.addEventListener('click', () => {
shuffleOn = !shuffleOn;
btnShuffle.classList.toggle('active', shuffleOn);
rebuildQueue();
playCurrent().catch(showOverlay);
});
overlay.addEventListener('click', startRadio);
startBtn.addEventListener('click', (e) => { e.stopPropagation(); startRadio(); });
const attempt = playCurrent();
if (attempt && typeof attempt.then === 'function') {
attempt.then(() => { started = true; hideOverlay(); }).catch(showOverlay);
} else {
showOverlay();
}
}
init();
</script>
</body>
</html>