6e92841352
Harden DeepSeek JSON parsing with retry, pre-sanitize Lyria prompts, and instrumental fallback. Add pure HTML Winamp skin at /winamp with playlist export support. Co-authored-by: Cursor <cursoragent@cursor.com>
485 lines
18 KiB
HTML
485 lines
18 KiB
HTML
<!--
|
||
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">|<<</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">>>|</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>
|