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>
269 lines
12 KiB
HTML
269 lines
12 KiB
HTML
<!--
|
|
title: Live Ozan Radio
|
|
date: 2026-06-07
|
|
author: Ozan
|
|
source: live-radio
|
|
description: Auto-play AI radio — desert dub and Anadolu psych. Streams saved Lyria tracks from Git LFS on tinqs.com.
|
|
-->
|
|
<!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</title>
|
|
<meta name="description" content="Personal AI radio — auto-play desert dub and Anadolu psych from tinqs.com">
|
|
<style>
|
|
:root {
|
|
--bg: #0a0a0f;
|
|
--panel: #14141f;
|
|
--accent: #ff6b35;
|
|
--accent2: #7b5cff;
|
|
--text: #f0f0f5;
|
|
--muted: #8888a0;
|
|
--glow: rgba(255, 107, 53, 0.35);
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { height: 100%; }
|
|
body {
|
|
font-family: "Segoe UI", system-ui, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background-image:
|
|
radial-gradient(ellipse 80% 50% at 50% -10%, var(--glow), transparent),
|
|
radial-gradient(ellipse 60% 40% at 100% 100%, rgba(123,92,255,0.15), transparent);
|
|
}
|
|
.shell {
|
|
width: min(440px, 92vw);
|
|
background: var(--panel);
|
|
border: 1px solid #2a2a3a;
|
|
border-radius: 20px;
|
|
padding: 2rem;
|
|
box-shadow: 0 24px 80px rgba(0,0,0,0.5);
|
|
text-align: center;
|
|
}
|
|
.badge {
|
|
display: inline-block;
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--accent);
|
|
border: 1px solid var(--accent);
|
|
border-radius: 4px;
|
|
padding: 0.2rem 0.5rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
h1 { font-size: 1.6rem; margin-bottom: 0.35rem; }
|
|
.tagline { color: var(--muted); font-size: 0.85rem; line-height: 1.45; margin-bottom: 1.25rem; }
|
|
.viz {
|
|
height: 56px;
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: center;
|
|
gap: 4px;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.bar {
|
|
width: 6px;
|
|
background: linear-gradient(to top, var(--accent), var(--accent2));
|
|
border-radius: 3px;
|
|
animation: bounce 0.8s ease-in-out infinite alternate;
|
|
}
|
|
.bar:nth-child(1) { height: 20px; animation-delay: 0s; }
|
|
.bar:nth-child(2) { height: 36px; animation-delay: 0.1s; }
|
|
.bar:nth-child(3) { height: 52px; animation-delay: 0.2s; }
|
|
.bar:nth-child(4) { height: 40px; animation-delay: 0.15s; }
|
|
.bar:nth-child(5) { height: 28px; animation-delay: 0.05s; }
|
|
.viz.paused .bar { animation-play-state: paused; opacity: 0.35; }
|
|
@keyframes bounce {
|
|
from { transform: scaleY(0.4); opacity: 0.6; }
|
|
to { transform: scaleY(1); opacity: 1; }
|
|
}
|
|
.title { font-size: 1.15rem; font-weight: 600; margin-bottom: 0.35rem; min-height: 1.4em; }
|
|
.mood, .dj { font-size: 0.85rem; color: var(--muted); margin-bottom: 0.45rem; min-height: 1.2em; }
|
|
.dj { font-style: italic; color: #b0b0c8; }
|
|
audio { width: 100%; margin: 0.75rem 0; border-radius: 8px; }
|
|
.status { font-size: 0.75rem; color: var(--muted); min-height: 1.2em; }
|
|
.overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(10, 10, 15, 0.92);
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 10;
|
|
cursor: pointer;
|
|
}
|
|
.overlay.show { display: flex; }
|
|
.overlay button {
|
|
background: linear-gradient(135deg, var(--accent), #ff8f65);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 14px;
|
|
padding: 1rem 1.5rem;
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
.links { margin-top: 1.25rem; font-size: 0.72rem; color: #555568; line-height: 1.6; }
|
|
.links a { color: var(--accent); text-decoration: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="overlay" id="overlay" aria-hidden="true">
|
|
<button type="button" id="startBtn">▶ Start Live Ozan Radio</button>
|
|
</div>
|
|
|
|
<div class="shell">
|
|
<div class="badge" id="badge">● Live</div>
|
|
<h1>Ozan Radio</h1>
|
|
<p class="tagline">No catalog tracks — AI-composed desert dub & Anadolu psych, hosted on tinqs.com.</p>
|
|
|
|
<div class="viz" id="viz">
|
|
<div class="bar"></div><div class="bar"></div><div class="bar"></div>
|
|
<div class="bar"></div><div class="bar"></div>
|
|
</div>
|
|
|
|
<div class="title" id="trackTitle">Loading…</div>
|
|
<div class="mood" id="trackMood"></div>
|
|
<div class="dj" id="trackDj"></div>
|
|
|
|
<audio id="player" preload="auto" autoplay playsinline></audio>
|
|
<div class="status" id="status">Connecting…</div>
|
|
|
|
<div class="links">
|
|
Full DJ + Lyria dashboard (local): <code>python -m ozan_radio serve</code><br>
|
|
<a href="https://tinqs.com/tinqs/live-radio" target="_blank" rel="noopener">Repo on Git Studio</a>
|
|
· <a href="player.html">Player shell</a> (needs API)
|
|
</div>
|
|
</div>
|
|
|
|
<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 viz = document.getElementById('viz');
|
|
const trackTitle = document.getElementById('trackTitle');
|
|
const trackMood = document.getElementById('trackMood');
|
|
const trackDj = document.getElementById('trackDj');
|
|
const statusEl = document.getElementById('status');
|
|
const overlay = document.getElementById('overlay');
|
|
const startBtn = document.getElementById('startBtn');
|
|
|
|
let playlist = null;
|
|
let queue = [];
|
|
let index = 0;
|
|
let started = false;
|
|
|
|
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 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 showTrack(t) {
|
|
trackTitle.textContent = t.title || 'Untitled';
|
|
trackMood.textContent = t.mood ? `Mood: ${t.mood}` : '';
|
|
trackDj.textContent = t.dj_line ? `"${t.dj_line}"` : '';
|
|
statusEl.textContent = `Playing · ${t.id || ''}`;
|
|
}
|
|
|
|
function playCurrent() {
|
|
if (!queue.length) {
|
|
trackTitle.textContent = 'No tracks yet';
|
|
statusEl.textContent = 'Push songs to main — see README';
|
|
return;
|
|
}
|
|
const t = queue[index];
|
|
showTrack(t);
|
|
const url = t.url || (playlist.media_base + t.file);
|
|
if (player.src !== url) player.src = url;
|
|
return player.play();
|
|
}
|
|
|
|
function nextTrack() {
|
|
if (!queue.length) return;
|
|
index = (index + 1) % queue.length;
|
|
playCurrent().catch(showOverlay);
|
|
}
|
|
|
|
function showOverlay() {
|
|
overlay.classList.add('show');
|
|
overlay.setAttribute('aria-hidden', 'false');
|
|
statusEl.textContent = 'Tap to start audio (browser policy)';
|
|
}
|
|
|
|
function hideOverlay() {
|
|
overlay.classList.remove('show');
|
|
overlay.setAttribute('aria-hidden', 'true');
|
|
}
|
|
|
|
function startRadio() {
|
|
if (started) return;
|
|
started = true;
|
|
hideOverlay();
|
|
playCurrent().catch(showOverlay);
|
|
}
|
|
|
|
async function init() {
|
|
playlist = playlist || parseEmbeddedPlaylist();
|
|
if (!playlist || !playlist.tracks || !playlist.tracks.length) {
|
|
try {
|
|
const res = await fetch('playlist.json');
|
|
if (res.ok) playlist = await res.json();
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (!playlist || !playlist.tracks || !playlist.tracks.length) {
|
|
trackTitle.textContent = 'Library empty';
|
|
statusEl.textContent = 'Generate tracks locally, export, push to main';
|
|
return;
|
|
}
|
|
|
|
queue = shuffle(playlist.tracks);
|
|
index = 0;
|
|
document.title = playlist.station || 'Live Ozan Radio';
|
|
|
|
player.addEventListener('ended', nextTrack);
|
|
player.addEventListener('play', () => viz.classList.remove('paused'));
|
|
player.addEventListener('pause', () => viz.classList.add('paused'));
|
|
|
|
const attempt = playCurrent();
|
|
if (attempt && typeof attempt.then === 'function') {
|
|
attempt.then(() => { started = true; hideOverlay(); }).catch(showOverlay);
|
|
} else {
|
|
showOverlay();
|
|
}
|
|
}
|
|
|
|
overlay.addEventListener('click', startRadio);
|
|
startBtn.addEventListener('click', (e) => { e.stopPropagation(); startRadio(); });
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === ' ' || e.key === 'Enter') startRadio();
|
|
});
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|