e15b5e9f98
Shift lane to Jon Hopkins Singularity and Karsh Kale fusion (not Turkish or slow country homages). Add genres.json, curate Chac's Dub and Frostbite Dub as keepers, export genres on gateway playlist, and trim library to seven tracks after batch cleanup. Co-authored-by: Cursor <cursoragent@cursor.com>
269 lines
11 KiB
HTML
269 lines
11 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": "No catalog. AI-composed desert dub + Anadolu psych.", "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", "updated": "2026-06-07T15:23:22.324250+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"]}]}
|
|
/*__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>
|