Files
ozan 6e92841352 Fix generation JSON/Lyria errors, add Winamp player, and ship Echoes of the Sahel.
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>
2026-06-07 16:38:52 +01:00

485 lines
18 KiB
HTML

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
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>