Display current song

This commit is contained in:
2026-01-02 19:37:08 +01:00
parent c3f594d102
commit cb01a59051
5 changed files with 312 additions and 32 deletions

View File

@@ -31,6 +31,19 @@ const deviceListEl = document.getElementById('device-list');
const logoTextEl = document.querySelector('.station-logo-text');
const logoImgEl = document.getElementById('station-logo-img');
const artworkPlaceholder = document.querySelector('.artwork-placeholder');
// Global error handlers to avoid silent white screen and show errors in UI
window.addEventListener('error', (ev) => {
try {
console.error('Uncaught error', ev.error || ev.message || ev);
if (statusTextEl) statusTextEl.textContent = 'Error: ' + (ev.error && ev.error.message ? ev.error.message : ev.message || 'Unknown');
} catch (e) { /* ignore */ }
});
window.addEventListener('unhandledrejection', (ev) => {
try {
console.error('Unhandled rejection', ev.reason);
if (statusTextEl) statusTextEl.textContent = 'Error: ' + (ev.reason && ev.reason.message ? ev.reason.message : String(ev.reason));
} catch (e) { /* ignore */ }
});
// Editor elements
const editBtn = document.getElementById('edit-stations-btn');
const editorOverlay = document.getElementById('editor-overlay');
@@ -46,10 +59,15 @@ const usIndex = document.getElementById('us_index');
// Init
async function init() {
try {
restoreSavedVolume();
await loadStations();
setupEventListeners();
updateUI();
} catch (e) {
console.error('Error during init', e);
if (statusTextEl) statusTextEl.textContent = 'Init error: ' + (e && e.message ? e.message : String(e));
}
}
// Volume persistence
@@ -154,58 +172,235 @@ async function loadStations() {
}
}
// --- Current Song Polling ---
const currentSongPollers = new Map(); // stationId -> intervalId
const currentSongPollers = new Map(); // stationId -> { intervalId, timeoutId }
// Attempt to discover the radio.svet24 asset query token used by lastsongs URLs.
// This fetches a stable asset JS (as used on the site) and extracts the value
// between `?` and `")` using a JS-friendly regex. It caches the token on the
// `station._assetToken` property so pollers can append it to lastsongs requests.
async function getAssetTokenForStation(station) {
try {
if (!station) return null;
if (station._assetToken) return station._assetToken;
// Primary known asset location used on the site
const assetUrl = 'https://radio.svet24.si/assets/index-IMUbJO1D.js';
let body = null;
try {
// Use backend proxy to avoid CORS
body = await invoke('fetch_url', { url: assetUrl });
} catch (e) {
// If fetching the known asset fails, try the station's homepage as a fallback
try {
const home = station.raw && (station.raw.www || station.raw.homepage || station.raw.url);
if (home) body = await invoke('fetch_url', { url: String(home) });
} catch (e2) {
// swallow fallback errors
}
}
if (!body) return null;
// Extract the token: grep -oP '\?\K[^"]+(?="\))' equivalence in JS
// We'll capture the group after the question mark and before the '"))' sequence.
const m = body.match(/\?([^"\)]+)(?="\))/);
if (m && m[1]) {
const token = m[1].replace(/^\?/, '');
station._assetToken = token;
console.debug('getAssetTokenForStation: found token for', station.id || station.name, token);
return token;
}
console.debug('getAssetTokenForStation: token not found in fetched asset for', station.id || station.name);
return null;
} catch (e) {
console.debug('getAssetTokenForStation failed', e && e.message ? e.message : e);
return null;
}
}
function stopCurrentSongPollers() {
for (const id of currentSongPollers.values()) {
clearInterval(id);
for (const entry of currentSongPollers.values()) {
try { if (entry && entry.intervalId) clearInterval(entry.intervalId); } catch (e) {}
try { if (entry && entry.timeoutId) clearTimeout(entry.timeoutId); } catch (e) {}
}
currentSongPollers.clear();
}
function startCurrentSongPollers() {
// Clear existing
// Clear existing (we only run poller for the currently selected station)
stopCurrentSongPollers();
stations.forEach((s, idx) => {
const url = s.raw && s.raw.currentSong;
const idx = currentIndex;
const s = stations[idx];
if (!s) return;
// Prefer explicit `currentSong` endpoint, fall back to `lastSongs` endpoint
let url = s.raw && (s.raw.currentSong || s.raw.lastSongs);
if (url && typeof url === 'string' && url.length > 0) {
// fetch immediately and then every 10s
fetchAndStoreCurrentSong(s, idx, url);
const iid = setInterval(() => fetchAndStoreCurrentSong(s, idx, url), 10000);
currentSongPollers.set(s.id || idx, iid);
const baseUrl = String(url);
// Create a wrapper that computes the effective fetch URL each time so that
// any token discovered asynchronously on the station object is applied
// without restarting the poller.
const doFetch = () => {
try {
let fetchUrl = baseUrl;
if (/lastsongsxml/i.test(fetchUrl)) {
const token = s._assetToken || null;
if (token) {
fetchUrl += (fetchUrl.includes('?') ? '&' : '?') + token;
} else {
// temporary cache-busting param while token discovery runs
fetchUrl += (fetchUrl.includes('?') ? '&' : '?') + Date.now();
// start background retrieval of token for future requests
getAssetTokenForStation(s).catch(() => {});
}
}
fetchAndStoreCurrentSong(s, idx, fetchUrl);
} catch (e) {
console.debug('doFetch wrapper error for station', s.id || idx, e);
}
};
// fetch immediately and then every 10s
doFetch();
const iid = setInterval(doFetch, 10000);
// save baseUrl for potential restart and track interval/timeout
s._lastSongsBaseUrl = baseUrl;
currentSongPollers.set(s.id || idx, { intervalId: iid, timeoutId: null });
}
});
}
async function fetchAndStoreCurrentSong(station, idx, url) {
try {
let data = null;
try {
const resp = await fetch(url, { cache: 'no-store' });
const ct = resp.headers.get('content-type') || '';
if (ct.includes('application/json')) {
data = await resp.json();
// If the URL is remote (http/https), use the backend `fetch_url` to bypass CORS.
let rawBody = null;
if (/^https?:\/\//i.test(url)) {
rawBody = await invoke('fetch_url', { url });
} else {
const txt = await resp.text();
try { data = JSON.parse(txt); } catch (e) { data = null; }
const resp = await fetch(url, { cache: 'no-store' });
rawBody = await resp.text();
}
} catch (fetchErr) {
// Possibly blocked by CORS — fall back to backend fetch via Tauri invoke
// Debug: show fetched body length (avoid huge dumps)
console.debug('fetchAndStoreCurrentSong fetched', url, 'len=', rawBody ? String(rawBody.length) : 'null');
// Try to parse JSON. Some endpoints double-encode JSON as a string (i.e.
// the response is a JSON string containing escaped JSON). Handle both
// cases: direct object, or string that needs a second parse.
try {
const body = await invoke('fetch_url', { url });
try { data = JSON.parse(body); } catch (e) { data = null; }
} catch (invokeErr) {
console.debug('Both fetch and backend fetch failed for', url, fetchErr, invokeErr);
if (!rawBody) {
data = null;
} else {
const first = JSON.parse(rawBody);
if (typeof first === 'string') {
// e.g. "{\"title\":...}"
try {
data = JSON.parse(first);
} catch (e2) {
// If second parse fails, attempt to unescape common backslashes and reparse
const unescaped = first.replace(/\\"/g, '"').replace(/\\n/g, '').replace(/\\/g, '');
try {
data = JSON.parse(unescaped);
} catch (e3) {
console.debug('Failed second-stage parse for', url, e3);
data = null;
}
}
} else {
data = first;
}
}
} catch (e) {
console.debug('Failed to parse JSON from', url, e);
data = null;
}
if (data && (data.artist || data.title)) {
station.currentSongInfo = { artist: data.artist || '', title: data.title || '' };
// Normalise different shapes to a currentSong object if possible
let now = null;
if (data) {
if (data.currentSong && (data.currentSong.artist || data.currentSong.title)) {
now = { artist: data.currentSong.artist || '', title: data.currentSong.title || '' };
} else if (Array.isArray(data.lastSongs) && data.lastSongs.length > 0) {
const first = data.lastSongs[0];
if (first && (first.artist || first.title)) now = { artist: first.artist || '', title: first.title || '' };
} else if (data.artist || data.title) {
now = { artist: data.artist || '', title: data.title || '' };
} else if (data.title && !data.artist) {
// Some stations use `title` only
now = { artist: '', title: data.title };
}
}
if (now) {
station.currentSongInfo = now;
// update UI if this is the currently loaded station
if (idx === currentIndex) updateNowPlayingUI();
// If we have timing info (from the provider), schedule a single-shot
// refresh to align with the next song instead of polling continuously.
try {
const key = station.id || idx;
// prefer the provider currentSong object if available
const providerCS = data && data.currentSong ? data.currentSong : null;
const startStr = providerCS && providerCS.playTimeStartSec ? providerCS.playTimeStartSec : (providerCS && providerCS.playTimeStart ? providerCS.playTimeStart : null);
const lengthStr = providerCS && providerCS.playTimeLengthSec ? providerCS.playTimeLengthSec : (providerCS && providerCS.playTimeLength ? providerCS.playTimeLength : null);
if (startStr && lengthStr) {
// parse start time `HH:MM:SS` (or `H:MM:SS`) and length `M:SS` or `MM:SS`
const nowDate = new Date();
const parts = startStr.split(':').map(p=>Number(p));
if (parts.length >= 2) {
const startDate = new Date(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate(), parts[0], parts[1], parts[2]||0, 0);
// adjust if start appears to be on previous day (large positive diff)
const deltaStart = startDate.getTime() - nowDate.getTime();
if (deltaStart > 12*3600*1000) startDate.setDate(startDate.getDate() - 1);
if (deltaStart < -12*3600*1000) startDate.setDate(startDate.getDate() + 1);
const lenParts = lengthStr.split(':').map(p=>Number(p));
let lenSec = 0;
if (lenParts.length === 3) lenSec = lenParts[0]*3600 + lenParts[1]*60 + lenParts[2];
else if (lenParts.length === 2) lenSec = lenParts[0]*60 + lenParts[1];
else lenSec = Number(lenParts[0]) || 0;
const endMs = startDate.getTime() + (lenSec * 1000);
const msUntilEnd = endMs - nowDate.getTime();
if (msUntilEnd > 1000) {
// Clear existing interval and timeouts for this station
const entry = currentSongPollers.get(key);
if (entry && entry.intervalId) {
try { clearInterval(entry.intervalId); } catch (e) {}
}
if (entry && entry.timeoutId) {
try { clearTimeout(entry.timeoutId); } catch (e) {}
}
// Schedule a one-shot fetch at song end. After firing, restart pollers
const timeoutId = setTimeout(async () => {
try {
// re-fetch using the same effective URL (may include token)
await fetchAndStoreCurrentSong(station, idx, url);
} catch (e) {
console.debug('scheduled fetch failed for', key, e);
} finally {
// If still on this station, restart the regular poller
if (currentIndex === idx) startCurrentSongPollers();
}
}, msUntilEnd + 250);
currentSongPollers.set(key, { intervalId: null, timeoutId });
console.debug('Scheduled next fetch for', key, 'in ms=', msUntilEnd);
}
}
}
} catch (e) {
console.debug('Failed scheduling next-song fetch', e);
}
}
} catch (e) {
// ignore fetch errors silently (network/CORS) but keep console for debugging
@@ -455,6 +650,8 @@ function loadStation(index) {
}
logoTextEl.classList.remove('hidden');
}
// When loading a station, ensure only this station's poller runs
try { startCurrentSongPollers(); } catch (e) { console.debug('startCurrentSongPollers failed in loadStation', e); }
}
// Check if an image URL is reachable and valid

11
tools/check_counts.js Normal file
View File

@@ -0,0 +1,11 @@
const fs = require('fs');
const path = 'd:/Sites/Work/RadioCast/src/main.js';
const s = fs.readFileSync(path,'utf8');
const counts = {'(':0,')':0,'{':0,'}','[':0,']':0,'`':0,'"':0,"'":0};
for (const ch of s) { if (counts.hasOwnProperty(ch)) counts[ch]++; }
console.log('counts:', counts);
// Also print last 50 characters and line count
console.log('length:', s.length);
const lines = s.split(/\r?\n/);
console.log('lines:', lines.length);
for (let i = Math.max(0, lines.length-20); i < lines.length; i++) console.log((i+1)+': '+lines[i]);

24
tools/find_unclosed.cjs Normal file
View File

@@ -0,0 +1,24 @@
const fs = require('fs');
const path = 'd:/Sites/Work/RadioCast/src/main.js';
const s = fs.readFileSync(path,'utf8');
const lines = s.split(/\r?\n/);
let balance = 0;
let maxBalance = 0;
let maxLine = -1;
for (let i=0;i<lines.length;i++){
const line = lines[i];
for (const ch of line){
if (ch==='{' ) balance++;
else if (ch==='}') balance--;
}
if (balance>maxBalance){ maxBalance = balance; maxLine = i+1; }
}
console.log('final balance:', balance, 'maxBalance:', maxBalance, 'maxLine:', maxLine);
console.log('last 40 lines:');
for (let i=Math.max(0, lines.length-40); i<lines.length;i++) console.log((i+1)+': '+lines[i]);
// Print context around maxLine
if (maxLine>0){
console.log('\nContext around max imbalance at line', maxLine);
for (let i=Math.max(1, maxLine-5); i<=Math.min(lines.length, maxLine+5); i++) console.log(i+': '+lines[i-1]);
}

24
tools/find_unclosed.js Normal file
View File

@@ -0,0 +1,24 @@
const fs = require('fs');
const path = 'd:/Sites/Work/RadioCast/src/main.js';
const s = fs.readFileSync(path,'utf8');
const lines = s.split(/\r?\n/);
let balance = 0;
let maxBalance = 0;
let maxLine = -1;
for (let i=0;i<lines.length;i++){
const line = lines[i];
for (const ch of line){
if (ch==='{' ) balance++;
else if (ch==='}') balance--;
}
if (balance>maxBalance){ maxBalance = balance; maxLine = i+1; }
}
console.log('final balance:', balance, 'maxBalance:', maxBalance, 'maxLine:', maxLine);
console.log('last 40 lines:');
for (let i=Math.max(0, lines.length-40); i<lines.length;i++) console.log((i+1)+': '+lines[i]);
// Print context around maxLine
if (maxLine>0){
console.log('\nContext around max imbalance at line', maxLine);
for (let i=Math.max(1, maxLine-5); i<=Math.min(lines.length, maxLine+5); i++) console.log(i+': '+lines[i-1]);
}

24
tools/find_unmatched.cjs Normal file
View File

@@ -0,0 +1,24 @@
const fs=require('fs');
const path='d:/Sites/Work/RadioCast/src/main.js';
const s=fs.readFileSync(path,'utf8');
const lines=s.split(/\r?\n/);
const stack=[];
for(let i=0;i<lines.length;i++){
const line=lines[i];
for(let j=0;j<line.length;j++){
const ch=line[j];
if(ch==='{' ) stack.push({line:i+1,col:j+1});
else if(ch==='}'){
if(stack.length>0) stack.pop(); else console.log('Unmatched closing } at',i+1,j+1);
}
}
}
console.log('Unmatched openings left:', stack.length);
if(stack.length>0) console.log('First unmatched opening at', stack[0]);
if(stack.length>0) console.log('Last unmatched opening at', stack[stack.length-1]);
if(stack.length>0){
const sidx=Math.max(1, stack[stack.length-1].line-5);
const eidx=Math.min(lines.length, stack[stack.length-1].line+5);
console.log('\nContext:');
for(let i=sidx;i<=eidx;i++) console.log(i+': '+lines[i-1]);
}