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>
|