Compare commits

...

3 Commits

Author SHA1 Message Date
f2732b36f2 update 2026-01-02 20:31:15 +01:00
7c0a202f16 fix 2026-01-02 19:38:16 +01:00
cb01a59051 Display current song 2026-01-02 19:37:08 +01:00
8 changed files with 400 additions and 67 deletions

View File

@@ -18,6 +18,13 @@
cursor: default; cursor: default;
} }
/* Show pointer cursor for interactive / clickable elements (override global default) */
a, a[href], button, input[type="button"], input[type="submit"],
[role="button"], [onclick], .clickable, .icon-btn, .control-btn, label[for],
.station-item, [tabindex]:not([tabindex="-1"]) {
cursor: pointer !important;
}
/* Hide Scrollbars */ /* Hide Scrollbars */
::-webkit-scrollbar { ::-webkit-scrollbar {
display: none; display: none;
@@ -258,6 +265,22 @@ header {
pointer-events: none; pointer-events: none;
} }
/* Make artwork/logo clickable: show pointer cursor */
.artwork-placeholder,
.artwork-placeholder:hover,
.station-logo-img,
.station-logo-text {
cursor: pointer !important;
pointer-events: auto;
}
/* Subtle hover affordance to make clickability clearer */
.artwork-placeholder:hover .station-logo-img,
.artwork-placeholder:hover .station-logo-text {
transform: scale(1.03);
transition: transform 160ms ease;
}
.blob { .blob {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;

View File

@@ -31,6 +31,19 @@ const deviceListEl = document.getElementById('device-list');
const logoTextEl = document.querySelector('.station-logo-text'); const logoTextEl = document.querySelector('.station-logo-text');
const logoImgEl = document.getElementById('station-logo-img'); const logoImgEl = document.getElementById('station-logo-img');
const artworkPlaceholder = document.querySelector('.artwork-placeholder'); 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 // Editor elements
const editBtn = document.getElementById('edit-stations-btn'); const editBtn = document.getElementById('edit-stations-btn');
const editorOverlay = document.getElementById('editor-overlay'); const editorOverlay = document.getElementById('editor-overlay');
@@ -46,10 +59,16 @@ const usIndex = document.getElementById('us_index');
// Init // Init
async function init() { async function init() {
restoreSavedVolume(); try {
await loadStations(); restoreSavedVolume();
setupEventListeners(); await loadStations();
updateUI(); setupEventListeners();
ensureArtworkPointerFallback();
updateUI();
} catch (e) {
console.error('Error during init', e);
if (statusTextEl) statusTextEl.textContent = 'Init error: ' + (e && e.message ? e.message : String(e));
}
} }
// Volume persistence // Volume persistence
@@ -154,58 +173,235 @@ async function loadStations() {
} }
} }
// --- Current Song Polling --- // --- 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() { function stopCurrentSongPollers() {
for (const id of currentSongPollers.values()) { for (const entry of currentSongPollers.values()) {
clearInterval(id); try { if (entry && entry.intervalId) clearInterval(entry.intervalId); } catch (e) {}
try { if (entry && entry.timeoutId) clearTimeout(entry.timeoutId); } catch (e) {}
} }
currentSongPollers.clear(); currentSongPollers.clear();
} }
function startCurrentSongPollers() { function startCurrentSongPollers() {
// Clear existing // Clear existing (we only run poller for the currently selected station)
stopCurrentSongPollers(); stopCurrentSongPollers();
stations.forEach((s, idx) => { const idx = currentIndex;
const url = s.raw && s.raw.currentSong; const s = stations[idx];
if (url && typeof url === 'string' && url.length > 0) { if (!s) return;
// fetch immediately and then every 10s
fetchAndStoreCurrentSong(s, idx, url); // Prefer explicit `currentSong` endpoint, fall back to `lastSongs` endpoint
const iid = setInterval(() => fetchAndStoreCurrentSong(s, idx, url), 10000); let url = s.raw && (s.raw.currentSong || s.raw.lastSongs);
currentSongPollers.set(s.id || idx, iid);
} if (url && typeof url === 'string' && url.length > 0) {
}); 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) { async function fetchAndStoreCurrentSong(station, idx, url) {
try { try {
let data = null; let data = null;
try { // If the URL is remote (http/https), use the backend `fetch_url` to bypass CORS.
const resp = await fetch(url, { cache: 'no-store' }); let rawBody = null;
const ct = resp.headers.get('content-type') || ''; if (/^https?:\/\//i.test(url)) {
if (ct.includes('application/json')) { rawBody = await invoke('fetch_url', { url });
data = await resp.json();
} else { } else {
const txt = await resp.text(); const resp = await fetch(url, { cache: 'no-store' });
try { data = JSON.parse(txt); } catch (e) { data = null; } 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 { try {
const body = await invoke('fetch_url', { url }); if (!rawBody) {
try { data = JSON.parse(body); } catch (e) { data = null; } data = null;
} catch (invokeErr) { } else {
console.debug('Both fetch and backend fetch failed for', url, fetchErr, invokeErr); 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; data = null;
} }
// 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 (data && (data.artist || data.title)) { if (now) {
station.currentSongInfo = { artist: data.artist || '', title: data.title || '' }; station.currentSongInfo = now;
// update UI if this is the currently loaded station // update UI if this is the currently loaded station
if (idx === currentIndex) updateNowPlayingUI(); 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) { } catch (e) {
// ignore fetch errors silently (network/CORS) but keep console for debugging // ignore fetch errors silently (network/CORS) but keep console for debugging
@@ -218,9 +414,10 @@ function updateNowPlayingUI() {
if (!station) return; if (!station) return;
if (nowPlayingEl && nowArtistEl && nowTitleEl) { if (nowPlayingEl && nowArtistEl && nowTitleEl) {
if (station.currentSongInfo && station.currentSongInfo.artist && station.currentSongInfo.title) { // Show now-playing if we have either an artist or a title (some stations only provide title)
nowArtistEl.textContent = station.currentSongInfo.artist; if (station.currentSongInfo && (station.currentSongInfo.artist || station.currentSongInfo.title)) {
nowTitleEl.textContent = station.currentSongInfo.title; nowArtistEl.textContent = station.currentSongInfo.artist || '';
nowTitleEl.textContent = station.currentSongInfo.title || '';
nowPlayingEl.classList.remove('hidden'); nowPlayingEl.classList.remove('hidden');
} else { } else {
nowArtistEl.textContent = ''; nowArtistEl.textContent = '';
@@ -416,6 +613,45 @@ function setupEventListeners() {
// Hotkeys? // Hotkeys?
} }
// If CSS doesn't produce a pointer, this helper forces a pointer when the
// mouse is inside the artwork placeholder's bounding rect. This handles
// cases where an invisible overlay or ancestor blocks pointer styling.
function ensureArtworkPointerFallback() {
try {
const ap = artworkPlaceholder;
if (!ap) return;
// Quick inline style fallback (helps when CSS is overridden)
try { ap.style.cursor = 'pointer'; } catch (e) {}
try { if (logoImgEl) logoImgEl.style.cursor = 'pointer'; } catch (e) {}
try { if (logoTextEl) logoTextEl.style.cursor = 'pointer'; } catch (e) {}
let active = false;
const onMove = (ev) => {
try {
const r = ap.getBoundingClientRect();
const x = ev.clientX, y = ev.clientY;
const inside = x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
if (inside && !active) {
document.body.style.cursor = 'pointer';
active = true;
} else if (!inside && active) {
document.body.style.cursor = '';
active = false;
}
} catch (e) {}
};
window.addEventListener('mousemove', onMove);
// remove on unload
window.addEventListener('beforeunload', () => {
try { window.removeEventListener('mousemove', onMove); } catch (e) {}
});
} catch (e) {
console.debug('ensureArtworkPointerFallback failed', e);
}
}
function loadStation(index) { function loadStation(index) {
if (index < 0 || index >= stations.length) return; if (index < 0 || index >= stations.length) return;
const station = stations[index]; const station = stations[index];
@@ -444,17 +680,18 @@ function loadStation(index) {
} }
}); });
} else { } else {
// Fallback to single-letter/logo text // Fallback: show the full station name when no logo is provided
logoImgEl.src = ''; logoImgEl.src = '';
logoImgEl.classList.add('hidden'); logoImgEl.classList.add('hidden');
const numberMatch = station.name.match(/\d+/); try {
if (numberMatch) { logoTextEl.textContent = (station.name || '').trim();
logoTextEl.textContent = numberMatch[0]; } catch (e) {
} else { logoTextEl.textContent = '';
logoTextEl.textContent = station.name.charAt(0).toUpperCase();
} }
logoTextEl.classList.remove('hidden'); 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 // Check if an image URL is reachable and valid

View File

@@ -9,7 +9,6 @@
"poster": "", "poster": "",
"lastSongs": "http://data.radio.si/api/lastsongsxml/radio1/json", "lastSongs": "http://data.radio.si/api/lastsongsxml/radio1/json",
"epg": "http://spored.radio.si/api/now/radio1", "epg": "http://spored.radio.si/api/now/radio1",
"currentSong": "https://radio1.si/?handler=CurrentSong",
"defaultText": "www.radio1.si", "defaultText": "www.radio1.si",
"www": "https://www.radio1.si", "www": "https://www.radio1.si",
"mountPoints": [ "mountPoints": [
@@ -201,7 +200,6 @@
"liveAudio": "http://live.radio.si/Radio80", "liveAudio": "http://live.radio.si/Radio80",
"liveVideo": null, "liveVideo": null,
"poster": null, "poster": null,
"currentSong": "https://radio80.si/?handler=CurrentSong",
"lastSongs": "http://data.radio.si/api/lastsongsxml/radio80/json", "lastSongs": "http://data.radio.si/api/lastsongsxml/radio80/json",
"epg": "http://spored.radio.si/api/now/radio80", "epg": "http://spored.radio.si/api/now/radio80",
"defaultText": "www.radio80.si", "defaultText": "www.radio80.si",
@@ -1340,29 +1338,5 @@
"dabPass": null, "dabPass": null,
"dabDefaultImg": "http://media.radio.si/logo/dns/hit/320x240.png", "dabDefaultImg": "http://media.radio.si/logo/dns/hit/320x240.png",
"small": false "small": false
},
{
"id": "RadioCity",
"title": "Radio City",
"slogan": "",
"logo": "https://radiocity.si/data/uploads/2020/06/2025.02.10-City-Logo-5-small.png",
"liveAudio": "https://stream1.radiocity.si/CityMp3128.mp3",
"liveVideo": null,
"poster": "",
"lastSongs": "",
"epg": "",
"defaultText": "",
"www": "https://radiocity.si/",
"mountPoints": [
"CityMp3128"
],
"social": [],
"enabled": true,
"radioApiIO": "",
"rpUid": "",
"dabUser": null,
"dabPass": null,
"dabDefaultImg": null,
"small": false
} }
] ]

View File

@@ -394,6 +394,22 @@ body {
z-index: 0; z-index: 0;
} }
/* Make artwork/logo clickable: show pointer cursor */
.artwork-placeholder,
.artwork-placeholder:hover,
.station-logo-img,
.station-logo-text {
cursor: pointer !important;
pointer-events: auto;
}
/* Subtle hover affordance to make clickability clearer */
.artwork-placeholder:hover .station-logo-img,
.artwork-placeholder:hover .station-logo-text {
transform: scale(1.03);
transition: transform 160ms ease;
}
/* Track Info */ /* Track Info */
.track-info { .track-info {
text-align: center; text-align: center;

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]);
}