-
-
Save gAlleb/cd241f521e7aad22634c3301b8643352 to your computer and use it in GitHub Desktop.
// sse_hpnp.js | |
// | |
// | |
// 2023-12-01 Moonbase59 | |
// 2023-12-02 Moonbase59 - retry forever on errors, workaround for Chrome bug | |
// - add player autostart | |
// - add album art alt text, link title | |
// 2023-12-04 Moonbase59 - add localStorage cache for better UX | |
// 2023-12-05 Moonbase59 - code cleanup, add translatable strings | |
// - use event listener instead of .onreadystatechange | |
// - encapsulate in function so we don't pollute globals | |
// - multiple instances of this script now possible | |
// - autoplay now switchable (per instance of this script) | |
// 2023-12-07 Moonbase59 - changed to work with new HPNP API | |
// 2023-12-08 Moonbase59 - code optimization; example with live AzuraCast Demo Station | |
// - immediate Offline indication in case of EventSource failures | |
// 2023-12-13 Moonbase59 - change addClasses/removeClasses to spred syntax | |
// - show station offline in show name | |
// - revert HPNP to Centrifugo | |
// 2023-12-14 Moonbase59 - Update for new version that sends initial NP data on connect | |
// 2024-01-26 Moonbase59 - Add short/long timezone names and global time from server | |
// - Add elapsed/duration song data per station for progress bars. | |
// 2024-01-27 Moonbase59 - Add station time and time offset data (to help users with Schedule) | |
// - Fix negative minutes in sub-hour GMT offsets (would show "-6:-30") | |
// - Refactored "np-global-..." to "np-local-...". That's what it is. | |
// - Refactor progress bars, based on an idea by gAlleb (Stefan): | |
// Now initially gets elapsed & duration on song change only, | |
// and refreshes automatically every second via a "setInterval". | |
// Added logic to kill these if the station suddenly goes offline. | |
// 2024-01-28 Moonbase59 - Make elapsed seconds float, increases accuracy, allows different | |
// setInterval() times. | |
// 2024-01-29 Moonbase59 - Implement timezone from API (Azuracast RR 6b511b0 (2024-01-29)), | |
// with fallback for older versions. Assume station is on UTC if | |
// timezone can't be determined. | |
// - Fix bug with negative UTC offsets (returned an hour too much) | |
// - Show "0" in np-xxx-station-timediff-minutes element. | |
// 2024-01-31 Moonbase59 - Add np-xxx-song-duration, np-xxx-song-elapsed. | |
// - Add np-xxx-song-progressbar which updates the width % on an | |
// element like a simple <div> progress bar. | |
// 2024-02-01 Moonbase59 - minSec(): Avoid times like "3:60" for 239.51 seconds being | |
// returned in np-xxx-song-elapsed and progress bar title, | |
// use Math.trunc() instead of Math.round() | |
// - Ensure np-xxx-song-progressbar width <= 100%, 100% on live. | |
// - Don’t let elapsed overrun duration, except on live (duration=0), | |
// a wish from Stefan (@gAlleb). | |
// - Update progress with every SSE update instead of every song, | |
// to re-sync "jumping" API elapsed values in case of jingles. | |
// - Force initial update on startProgressBar (don’t wait 1 second) | |
// 2024-02-02 Moonbase59 - Add missing "last_update" in startProgressBar. | |
// - Add station description as title attribute to np-xxx-station-name | |
// | |
// AzuraCast Now Playing SSE event listener for one or more stations | |
// Will update elements with class names structured like | |
// np-stationshortcode-item-subitem | |
// Example: | |
// <img class="np-niteradio-song-albumart" title="Artist - Title" src="" width=150 /> | |
// will be updated with the album cover of the current song on station 'niteradio' | |
// Usage: | |
// Save this JS somewhere in your web space and put something like this | |
// at the end of your HTML body: | |
// <script src="sse_np_direct.js"></script> | |
// wrap in a function so we don’t overlap globals with other instances | |
(function () { | |
// hard-coded video player location for now, API doesn’t yet provide | |
const video_player_url = "https://rock.omfm.ru/video"; | |
// station base URL | |
const baseUri = "https://radio.omfm.ru"; | |
// station shortcode(s) you wish to subscribe to | |
// use the real shortcodes here; class names will automatically be "kebab-cased", | |
// i.e. "azuratest_radio" → "azuratest-radio" | |
// AzuraCast Rolling Release 6b511b0 (2024-01-29) and newer provide tz data in the API. | |
// If you are on an older version, specify station timezone like this: | |
// "station:azuratest_radio": {timezone: "Etc/UTC"}, | |
let subs = { | |
"station:radio": {}, | |
//"station:other-station": {}, | |
//"station:third-station": {}, | |
"global:time": {} // server timestamp | |
}; | |
// allow autoplay (same domain only)? | |
const autoplay = false; | |
// set common SSE URL | |
const sseUri = baseUri + "/api/live/nowplaying/sse?cf_connect="+JSON.stringify({ | |
"subs": subs | |
}); | |
// init subscribers | |
Object.keys(subs).forEach((station) => { | |
subs[station]["nowplaying"] = null; | |
subs[station]["last_sh_id"] = null; | |
subs[station]["elapsed"] = 0; | |
subs[station]["duration"] = 0; | |
subs[station]["last_update"] = Date.now(); // time in ms of last progress bar update | |
subs[station]["interval_id"] = 0; // holds nonzero updateProgressBar interval ID | |
}); | |
// store "global:time" timestamp updates here | |
let serverTime = 0; | |
// Translatable strings | |
// Style the online, live, and request indicators using classes | |
// 'label', 'label-success' (green) and 'label-error' (red) in your CSS. | |
const t = { | |
"Album art. Click to listen.": "Album art. Click to listen.", // album art alt text | |
"Click to listen": "Click to listen", // player link title (tooltip) | |
"Click to view": "Click to view", // video player link title (tooltip) | |
"Live": "Live", // live indicator text | |
"Live: ": "Live: ", // prefix to streamer name on live shows | |
"Offline": "Offline", // offline indicator text | |
"Online": "Online", // online indicator text | |
"Song request": "Song request" // request indicator text | |
}; | |
// As an example, here are the German translations: | |
//const t = { | |
//"Album art. Click to listen.": "Albumcover. Klick zum Zuhören.", // album art alt text | |
//"Click to listen": "Klick zum Zuhören", // player link title (tooltip) | |
//"Click to view": "Klick zum Zusehen", // video player link title (tooltip) | |
//"Live": "Live", // live indicator text | |
//"Live: ": "Live: ", // prefix to streamer name on live shows | |
//"Offline": "Offline", // offline indicator text | |
//"Online": "Online", // online indicator text | |
//"Song request": "Musikwunsch" // request indicator text | |
//}; | |
// return short or long timezone name in user's locale | |
// type can be "short" or "long" | |
function getTimezoneName(type) { | |
const today = new Date(); | |
const short = today.toLocaleDateString(undefined); | |
const full = today.toLocaleDateString(undefined, { timeZoneName: type }); | |
// Trying to remove date from the string in a locale-agnostic way | |
const shortIndex = full.indexOf(short); | |
if (shortIndex >= 0) { | |
const trimmed = full.substring(0, shortIndex) + full.substring(shortIndex + short.length); | |
// by this time `trimmed` should be the timezone's name with some punctuation - | |
// trim it from both sides | |
return trimmed.replace(/^[\s,.\-:;]+|[\s,.\-:;]+$/g, ''); | |
} else { | |
// in some magic case when short representation of date is not present in the long one, just return the long one as a fallback, since it should contain the timezone's name | |
return full; | |
} | |
} | |
// return hh:mm string from timestamp (used for show start/end times) | |
function getTimeFromTimestamp(timestamp) { | |
// convert a UNIX timestamp (seconds since epoch) | |
// to JS time (milliseconds since epoch) | |
let tmp = new Date(timestamp * 1000); | |
//let hrs = String(tmp.getHours()); | |
//let min = String(tmp.getMinutes()); | |
//return (hrs.length===1 ? "0"+hrs : hrs) + ":" | |
// + (min.length===1 ? "0"+min : min); | |
return tmp.getHours().toString().padStart(2,'0') + ":" | |
+ tmp.getMinutes().toString().padStart(2,'0'); | |
} | |
// return MM:SS from seconds | |
function minSec(duration) { | |
// const minutes = Math.trunc(duration / 60); | |
// const seconds = Math.round(duration % 60); | |
// return `${String(minutes)}:${String(seconds).padStart(2, '0')}`; | |
const minutes = Math.trunc(duration / 60); | |
const seconds = Math.trunc(duration % 60); | |
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; | |
} | |
function time2TimeAgo(ts) { | |
var d = new Date(); // Gets the current time | |
var nowTs = Math.floor(d.getTime()/1000); | |
// getTime() returns milliseconds, and we need seconds, hence the Math.floor and division by 1000 | |
var seconds = nowTs-ts; | |
// more that two days | |
if (seconds > 2*24*3600) { | |
return "a few days ago"; | |
} | |
// a day | |
if (seconds > 24*3600) { | |
return "yesterday"; | |
} | |
if (seconds > 3600) { | |
return "a few hours ago"; | |
} | |
if (seconds > 1800) { | |
return "Half an hour ago"; | |
} | |
if (seconds > 60) { | |
return Math.floor(seconds/60) + " minutes ago"; | |
} | |
} | |
// return station time data and offset to user’s local time | |
function getStationTime(station) { | |
// AzuraCast Rolling Release 6b511b0 (2024-01-29) and newer provide tz data in the API | |
let tz = subs[station]?.nowplaying?.station?.timezone || undefined; | |
// timezone fallback: API → subs[station].timezone → "Etc/UTC" | |
tz = tz || subs[station]?.timezone || "Etc/UTC"; | |
const now = new Date(); | |
// tz == undefined will result in zero difference | |
const nowStation = new Date(now.toLocaleString("en-US", {timeZone: tz})); | |
const diffMinutes = Math.round((nowStation - now) / 60000); | |
const hours = Math.trunc(diffMinutes / 60); | |
const minutes = Math.abs(diffMinutes % 60); | |
const stationTime = getTimeFromTimestamp(nowStation.getTime() / 1000); | |
const stationOffset = `${Intl.NumberFormat("en-US", | |
{signDisplay: "exceptZero"}).format(hours)}:${String(minutes).padStart(2, "0")}`; | |
//console.log(now, nowStation, tz, diffMinutes, hours, minutes); | |
return { | |
time: stationTime, | |
timezone: tz, | |
timediffHHMM: stationOffset, | |
timediffMinutes: diffMinutes | |
} | |
} | |
// Sanitize a station shortcode, so it can be used in a CSS class name | |
const toKebabCase = (str) => | |
str && | |
str | |
.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g) | |
.map((x) => x.toLowerCase()) | |
.join("-"); | |
function setElement(target, content, | |
{addClasses=null, attrib=null, style=null, removeClasses=null, timeconvert=false} = {}) { | |
// set elements with class="target" to content & modify/set attributes | |
// we use classes instead of ids because elements can occur multiple times | |
// will safely ignore any elements that are not used in the page | |
// (i.e. you don't have to have containers for all ids) | |
// content = "" or undefined means: set to empty | |
// content = null means: don’t touch content, just modify attribs | |
// this is used for user-named indicators ("is..." and "station-player") | |
let targets = Array.from(document.getElementsByClassName(target)); | |
targets.forEach((targ) => { | |
if (targ && content) { | |
// this target id is used on the page, load it | |
// normal node with content, i.e. <tag>content</tag> | |
if (timeconvert) { | |
targ.textContent = getTimeFromTimestamp(content); | |
} else { | |
targ.textContent = content; | |
} | |
} else if (targ && content !== null) { | |
// null = don’t modify (user can set in page) | |
// empty or undefined = set to empty | |
targ.textContent = ""; | |
} | |
// set attributes, if any | |
if (targ && attrib) { | |
Object.entries(attrib).forEach(([k,v]) => { | |
targ.setAttribute(k, v); | |
}); | |
} | |
// set styles, if any | |
if (targ && style) { | |
Object.entries(style).forEach(([k,v]) => { | |
targ.style[k] = v; | |
}); | |
} | |
// remove Classes, if any | |
if (targ && removeClasses) { | |
targ.classList.remove(...removeClasses); | |
} | |
// add Classes, if any | |
if (targ && addClasses) { | |
targ.classList.add(...addClasses); | |
} | |
}); | |
} | |
function startProgressBar(station, elapsed, duration) { | |
subs[station]["elapsed"] = elapsed; | |
subs[station]["duration"] = duration; | |
// start fresh point in time to later calculate "elapsed" from | |
subs[station]["last_update"] = Date.now(); | |
if (subs[station]["interval_id"] == 0) { | |
// start new updater interval if not already running: 1 update/second | |
subs[station]["interval_id"] = setInterval(updateProgressBar, 250, station); | |
} | |
// do an initial update, don’t want to wait a second | |
updateProgressBar(station); | |
} | |
function stopProgressBar(station) { | |
if (subs[station]["interval_id"] !== 0) { | |
clearInterval(subs[station]["interval_id"]); | |
} | |
subs[station]["interval_id"] = 0; | |
} | |
function updateProgressBar(station) { | |
// CF subs look like "station:shortcode", remove "station:" | |
let ch = station.split(":")[1] || null; | |
// sanitize station shortcode for use in a CSS class name | |
ch = toKebabCase(ch); | |
// increment elapsed time every second | |
// This is NOT 1s each round, since the updating alse takes time, | |
// which would lead to increasing in accuracy on longer songs | |
// if we just added 1s each time round. | |
let now = Date.now(); // a millisecond timestamp | |
subs[station]["elapsed"] += (now - subs[station]["last_update"]) / 1000; | |
subs[station]["last_update"] = now; | |
// update <progress> progress bar element on page | |
setElement("np-" + ch + "-song-progress", null, { | |
attrib: { | |
"value": subs[station]["elapsed"], | |
"max": subs[station]["duration"], | |
"title": "Total Duration: " + minSec(subs[station]["duration"]) | |
// "title": minSec(subs[station]["elapsed"]) | |
}}); | |
// update simple <div> progressbars using width % | |
// if (subs[station]["elapsed"] >= subs[station]["duration"] && subs[station]["duration"] !== 0 ) { | |
// subs[station]["elapsed"] = subs[station]["duration"]; | |
// } | |
// Don’t let elapsed overrun duration, except on live (duration=0 in this case) | |
if (subs[station]["duration"] > 0 && subs[station]["elapsed"] > subs[station]["duration"]) { | |
subs[station]["elapsed"] = subs[station]["duration"]; | |
stopProgressBar(station); | |
} | |
// update simple <div> progressbars using width %; div/zero gets us "Infinity" | |
let width = subs[station]["elapsed"] / subs[station]["duration"] * 100.0; | |
width = width > 100 ? 100 : width; | |
setElement("np-" + ch + "-song-progressbar", null, { | |
style: { | |
"width": String(width)+"%", | |
}}); | |
// update np-xxx-song-elapsed text display element; np-xxx-song-duration stays unchanged | |
setElement("np-" + ch + "-song-elapsed", minSec(subs[station]["elapsed"])); | |
//console.log("updateProgressBar:", station, | |
// subs[station]["elapsed"], "/", subs[station]["duration"]); | |
} | |
function updatePage(station) { | |
// update elements on the page (per station) | |
// CF subs look like "station:shortcode", remove "station:" | |
let ch = station.split(":")[1] || null; | |
// sanitize station shortcode for use in a CSS class name | |
ch = toKebabCase(ch); | |
const np = subs[station]?.nowplaying || null; | |
// Update time every time | |
if (np) { | |
let stationTime = getStationTime(station); | |
setElement("np-" + ch + "-station-time", stationTime["time"]); | |
setElement("np-" + ch + "-station-timezone", stationTime["timezone"]); | |
setElement("np-" + ch + "-station-timediff-hhmm", stationTime["timediffHHMM"]); | |
setElement("np-" + ch + "-station-timediff-minutes", String(stationTime["timediffMinutes"])); | |
//console.log(stationTime); | |
setElement("np-local-time", getTimeFromTimestamp(Date.now() / 1000)); | |
setElement("np-local-timezone-short", getTimezoneName("short")); | |
setElement("np-local-timezone-long", getTimezoneName("long")); | |
setElement("np-" + ch + "-song-duration", minSec(np.now_playing.duration)); | |
// start self-updating song progress bar; also sets np-xxx-song-elapsed | |
startProgressBar(station, np.now_playing.elapsed, np.now_playing.duration); | |
//setElement("np-" + ch + "-song-elapsed", minSec(np.now_playing.elapsed)); | |
setElement("np-" + ch + "-song-history-1-played-ago",time2TimeAgo(np.song_history[0].played_at)); | |
setElement("np-" + ch + "-song-history-2-played-ago",time2TimeAgo(np.song_history[1].played_at)); | |
setElement("np-" + ch + "-song-history-3-played-ago",time2TimeAgo(np.song_history[2].played_at)); | |
setElement("np-" + ch + "-song-history-4-played-ago",time2TimeAgo(np.song_history[3].played_at)); | |
setElement("np-" + ch + "-song-history-5-played-ago",time2TimeAgo(np.song_history[4].played_at)); | |
}; | |
//console.log(np.now_playing.sh_id, subs[station]["last_sh_id"]); | |
// Only update page elements when Song Hash ID changes | |
if (np && (np.now_playing.sh_id !== subs[station]["last_sh_id"])) { | |
// Handle Now Playing data update as `np` variable. | |
console.log("Now Playing on " + ch | |
+ (np.is_online ? " (online)" : " (offline)") | |
+ ": " + np.now_playing.song.text); | |
subs[station]["last_sh_id"] = np.now_playing.sh_id; | |
//setElement("np-" + ch + "-sh-id", np.now_playing.sh_id); | |
// setElement("np-" + ch + "-song-progress", null, { | |
// attrib: { | |
// "max": np.now_playing.duration, | |
// // "value": np.now_playing.elapsed, | |
// "title": "Total Duration: " + minSec(np.now_playing.duration) + " | Don't click! You'll achieve nothing by that:)" | |
// }}); | |
// const track_update_interval = setInterval(updateProgress, 500); | |
// var elapsed = np.now_playing.elapsed; | |
// function updateProgress() { | |
// elapsed +=0.5; // add time every second | |
// setElement("np-" + ch + "-song-progress", null, { | |
// attrib: { | |
// "value": elapsed, | |
// }}); | |
// if (np && (np.now_playing.sh_id !== subs[station]["last_sh_id"])) { | |
// clearInterval(track_update_interval); | |
// } | |
// }; | |
setElement("np-" + ch + "-song-artist", np.now_playing.song.artist); | |
setElement("np-" + ch + "-song-title", np.now_playing.song.title); | |
setElement("np-" + ch + "-song-text", np.now_playing.song.text); // artist - title | |
// artist - title string trimming | |
var np_song_text = np.now_playing.song.text; | |
var length = 56; | |
var np_song_text_trim = np_song_text.length > length ? np_song_text.substring(0, 55) + "..." : np_song_text; | |
setElement("np-" + ch + "-song-text-trim", np_song_text_trim); // artist - title | |
// | |
setElement("np-" + ch + "-song-next-title",np.playing_next.song.title); | |
setElement("np-" + ch + "-song-next-artist",np.playing_next.song.artist); | |
setElement("np-" + ch + "-show-name-next",np.playing_next.playlist); | |
setElement("np-" + ch + "-song-albumart-next", "", { | |
attrib:{ | |
"src": np.playing_next.song.art | |
}}); | |
setElement("np-" + ch + "-song-next-fancybox", null, { | |
attrib:{ | |
"href": np.playing_next.song.art, | |
"data-fancybox": "gallery-next-song", | |
"data-caption": np.playing_next.song.text + "<br/>Album: " + np.playing_next.song.album | |
}}); | |
setElement("np-" + ch + "-song-history-artist-1",np.song_history[0].song.artist); | |
setElement("np-" + ch + "-song-history-title-1",np.song_history[0].song.title); | |
setElement("np-" + ch + "-song-albumart-history-1", "", { | |
attrib:{ | |
"src": np.song_history[0].song.art | |
}}); | |
setElement("np-" + ch + "-song-history-fancybox-1",null, { | |
attrib:{ | |
"href": np.song_history[0].song.art, | |
"data-fancybox": "gallery-historic-1", | |
"data-caption": np.song_history[0].song.text + "<br/>Album: " + np.song_history[1].song.album | |
}}); | |
setElement("np-" + ch + "-song-history-artist-2",np.song_history[1].song.artist); | |
setElement("np-" + ch + "-song-history-title-2",np.song_history[1].song.title); | |
setElement("np-" + ch + "-song-albumart-history-2", "", { | |
attrib:{ | |
"src": np.song_history[1].song.art | |
}}); | |
setElement("np-" + ch + "-song-history-fancybox-2",null, { | |
attrib:{ | |
"href": np.song_history[1].song.art, | |
"data-fancybox": "gallery-historic-2", | |
"data-caption": np.song_history[1].song.text + "<br/>Album: " + np.song_history[1].song.album | |
}}); | |
setElement("np-" + ch + "-song-history-artist-3",np.song_history[2].song.artist); | |
setElement("np-" + ch + "-song-history-title-3",np.song_history[2].song.title); | |
setElement("np-" + ch + "-song-albumart-history-3", "", { | |
attrib:{ | |
"src": np.song_history[2].song.art | |
}}); | |
setElement("np-" + ch + "-song-history-fancybox-3",null, { | |
attrib:{ | |
"href": np.song_history[2].song.art, | |
"data-fancybox": "gallery-historic-3", | |
"data-caption": np.song_history[2].song.text + "<br/>Album: " + np.song_history[1].song.album | |
}}); | |
setElement("np-" + ch + "-song-history-artist-4",np.song_history[3].song.artist); | |
setElement("np-" + ch + "-song-history-title-4",np.song_history[3].song.title); | |
setElement("np-" + ch + "-song-albumart-history-4", "", { | |
attrib:{ | |
"src": np.song_history[3].song.art | |
}}); | |
setElement("np-" + ch + "-song-history-fancybox-4",null, { | |
attrib:{ | |
"href": np.song_history[3].song.art, | |
"data-fancybox": "gallery-historic-4", | |
"data-caption": np.song_history[3].song.text + "<br/>Album: " + np.song_history[1].song.album | |
}}); | |
setElement("np-" + ch + "-song-history-artist-5",np.song_history[4].song.artist); | |
setElement("np-" + ch + "-song-history-title-5",np.song_history[4].song.title); | |
setElement("np-" + ch + "-song-albumart-history-5", "", { | |
attrib:{ | |
"src": np.song_history[4].song.art | |
}}); | |
setElement("np-" + ch + "-song-history-fancybox-5",null, { | |
attrib:{ | |
"href": np.song_history[4].song.art, | |
"data-fancybox": "gallery-historic-5", | |
"data-caption": np.song_history[4].song.text + "<br/>Album: " + np.song_history[1].song.album | |
}}); | |
setElement("np-" + ch + "-song-album", np.now_playing.song.album); | |
setElement("np-" + ch + "-song-albumart", "", { | |
attrib:{ | |
"alt": t["Album art. Click to listen."], | |
"src": np.now_playing.song.art | |
//"title": np.now_playing.song.text | |
}}); | |
setElement("np-" + ch + "-song-fancybox",null, { | |
attrib:{ | |
"href": np.now_playing.song.art, | |
"data-fancybox": "gallery-np", | |
"data-caption": np.now_playing.song.text + "<br/>Album: " + np.now_playing.song.album | |
}}); | |
setElement("np-" + ch + "-song-next-played-at",getTimeFromTimestamp(np.playing_next.played_at)); | |
setElement("np-" + ch + "-song-history-1-played-at",getTimeFromTimestamp(np.song_history[0].played_at)); | |
setElement("np-" + ch + "-song-history-2-played-at",getTimeFromTimestamp(np.song_history[1].played_at)); | |
setElement("np-" + ch + "-station-name", np.station.name, { | |
attrib:{ | |
"title": np.station.description | |
}}); | |
setElement("np-" + ch + "-station-description", np.station.description); | |
setElement("np-" + ch + "-station-url", np.station.url); | |
setElement("np-" + ch + "-station-player-url", np.station.public_player_url); | |
setElement("np-" + ch + "-station-player", null, { | |
attrib:{ | |
"href": np.station.public_player_url + (autoplay ? "?autoplay=true" : ""), | |
"target": "playerWindow", | |
"title": t["Click to listen"] | |
}}); | |
// hard-coded for now | |
if (video_player_url) { | |
setElement("np-" + ch + "-video-player-url", video_player_url); | |
setElement("np-" + ch + "-video-player", null, { | |
attrib:{ | |
"href": video_player_url, | |
"target": "playerWindow", | |
"title": t["Click to view"] | |
}}); | |
} else { | |
setElement("np-" + ch + "-video-player-url", ""); | |
setElement("np-" + ch + "-video-player", ""); | |
} | |
if ( np.is_online ) { | |
setElement("np-" + ch + "-station-isonline", t["Online"], { | |
addClasses: ["label-success"], | |
attrib: {"style": "display: inline;"}, | |
removeClasses: ["label-error"] | |
}); | |
} else { | |
setElement("np-" + ch + "-station-isonline", t["Offline"], { | |
addClasses: ["label-error"], | |
attrib: {"style": "display: inline;"}, | |
removeClasses: ["label-success"] | |
}); | |
// stop self-updating progress bar if one is running | |
stopProgressBar(station); | |
} | |
if ( np.live.is_live ) { | |
// live streamer, set indicator & show name | |
setElement("np-" + ch + "-show-islive", t["Live"], { | |
attrib: {"style": "display: inline;"} | |
}); | |
setElement("np-" + ch + "-show-name", t["Live: "] + np.live.streamer_name, { | |
removeClasses: ["label", "label-error"] | |
}); | |
// setElement("np-" + ch + "-song-progressbar", null, { | |
// style: { | |
// "width":"100%", | |
// }}); | |
} else { | |
// not live, hide indicator | |
setElement("np-" + ch + "-show-islive", t["Live"], { | |
attrib: {"style": "display: none;"} | |
}); | |
if ( np.is_online ) { | |
// not live && online: show name = playlist name | |
setElement("np-" + ch + "-show-name", np.now_playing.playlist, { | |
removeClasses: ["label", "label-error"] | |
}); | |
} else { | |
// not live && offline: show name = Offline indicator | |
setElement("np-" + ch + "-show-name", t["Offline"], { | |
addClasses: ["label", "label-error"] | |
}); | |
// stop self-updating progress bar if one is running | |
stopProgressBar(station); | |
} | |
} | |
if ( np.now_playing.is_request ) { | |
setElement("np-" + ch + "-song-isrequest", t["Song request"], { | |
attrib: {"style": "display: inline;"} | |
}); | |
} else { | |
setElement("np-" + ch + "-song-isrequest", t["Song request"], { | |
attrib: {"style": "display: none;"} | |
}); | |
} | |
} | |
} | |
function showOffline() { | |
// If EventSource failed, we might never get an offline message, | |
// so we update our status and the web page to let the user know immediately. | |
Object.keys(subs).forEach((station) => { | |
if (subs[station]["nowplaying"] && subs[station]["last_sh_id"] !== null) { | |
// only do this once – errors might repeat every few seconds | |
console.warn("Now Playing: Setting", station, "offline"); | |
subs[station]["nowplaying"]["is_online"] = false; | |
// reset last song hash id to force updatePage() | |
subs[station]["last_sh_id"] = null; | |
updatePage(station); // should also handle stopping progress bars | |
// reset last song hash id again since overwritten by updatePage | |
// This guarantees a fresh update on a later reconnect. | |
subs[station]["last_sh_id"] = null; | |
} | |
}); | |
} | |
let evtSource = null; | |
function initEvents() { | |
// currently, we have to set up one connection per station | |
if (evtSource === null || evtSource.readyState === 2) { | |
evtSource = new EventSource(sseUri); | |
evtSource.onerror = (err) => { | |
console.error("Now Playing: EventSource failed:", err); | |
// We might not have gotten an "offline" event, so better | |
// force "Station Offline" and user will know something is wrong | |
showOffline(); | |
// no special restart handler anymore -- will retry forever if not closed | |
// this works around the dreaded Chrome net::ERR_NETWORK_CHANGED error | |
// Note that on SEVERE errors like server unreachable, no network, etc. | |
// the EventSource will give up and we deliberately NOT try a reconnection | |
// (might overload already overloaded servereven more). | |
// Let the user press F5 to refresh page in this case. | |
}; | |
evtSource.onopen = function() { | |
console.log("Now Playing: Server connected."); | |
}; | |
function handleData(payload) { | |
// handle data for server time or a single station | |
const jsonData = payload?.pub?.data ?? {}; | |
if (payload.channel === 'global:time') { | |
// This is a "time" ping to let you know what the current time | |
// is on the server, so you can properly display elapsed/remaining time | |
// for your tracks. It's in the form of a UNIX timestamp. | |
serverTime = jsonData.time; | |
} else { | |
// This is a now-playing event from a station. | |
// Update your now-playing data accordingly. | |
const station = "station:" + jsonData.np?.station?.shortcode || null; | |
if (station in subs) { | |
subs[station]["nowplaying"] = jsonData.np; | |
updatePage(station); | |
} | |
} | |
} | |
evtSource.onmessage = (event) => { | |
const jsonData = JSON.parse(event.data); | |
if ("connect" in jsonData) { | |
// Initial data is sent in the "connect" response as an array | |
// of rows similar to individual messages. | |
const initialData = jsonData.connect.data ?? []; | |
initialData.forEach((initialRow) => handleData(initialRow)); | |
} else if ("channel" in jsonData) { | |
handleData(jsonData); | |
} | |
} | |
} | |
} | |
// wait until DOM ready then start listening to SSE events | |
document.addEventListener('readystatechange', event => { | |
if (event.target.readyState === "complete") { | |
// Document complete. Must use 'complete' instead of 'interactive', | |
// otherwise onlick handlers in many CMS’es don't work correctly. | |
// start listening to SSE events | |
initEvents(); | |
} | |
}); | |
// end wrapper | |
})(); |
But we still don’t know the station’s real timezone, right? Because we don’t get it from AzuraCast.
And I don’t get <progress>
and positioning to work correctly on older Android phones… darn.
Looks like I got it… Do you—by chance—have a few different OS’es, Browsers, Android and iOS devices ready? So we could test if this page gives correct progress bars on all devices/browsers?
I switched one testing station to Moscow time, could you please check its schedule?
Oh, and are you on the Discord so we could test some things more privately?
I switched one testing station to Moscow time, could you please check its schedule?
Yeap. Shows correctly.
On Windows and MacOS too:) Sorry) Couldn't wait)
Also wrote such function. Works well:)
function tz_difference() {
const date = new Date();
let tz_difference= date.getTimezoneOffset();
let my_tz_difference = (tz_difference+180)/60;
if (my_tz_difference < 0) {
document.getElementById('time_difference').innerHTML ="Time shown is Europe/Moscow timezone (UTC+3). Times shown differs by −" + (my_tz_difference*-1) + " hours from your local time.";
}
else if (my_tz_difference > 0){
document.getElementById('time_difference').innerHTML ="Time shown is Europe/Moscow timezone (UTC+3). Times shown differs by +" + my_tz_difference + " hours from your local time.";
}
else {document.getElementById('time_difference').innerHTML = "Time shown is Europe/Moscow timezone (UTC+3).";
}
}


When in Moscow - doesn't tell about the difference of timezones:


Sometimes API fails and bar is always full until next track. Hm)
Yeah, that’s the "metadata sometimes missing" bug in AzuraCast. Thanks for testing! Unfortunately, GH won’t let me click on the images, shows blank page with "private user image". Anyway, seen enough ;-)
Great job with progress bar! Thanks!
Well, "together" is always better! :-) Guess I’ll put the new code in the Gist tomorrow.
Yeah, great idea! I'm already using it and loving it ;)
Looked at your tz_difference. Unfortunately, this will only work in countries where the UTC offset is fixed (i.e., Russia, since I believe you’ve gotten rid of daylight savings time switching). For all other cases (like Germany), where the UTC offset can actually vary (UTC+01:00 normal time, and UTC+02:00 daylight savings time here), simply using a fixed offset will fail. It must actually be found out for every current date & time, because we might just be on the boundary of switching to DST, or vice-versa.
We could use moment or Luxon libraries for this but I don’t want to introduce extra dependencies here. Too bad Javascript doesn’t store the timezone in the Date object—that really sucks.
Let me think of something…
Thanks @Moonbase59 ! Yeah, we should wait for the feature request. I have read about it yesterday and looked into moment
with bunch of different js and years they contain:)
Like the progress bar) Since I've accomodated the progress bar at the bottom of screen and fullsize - it seams a bit odd when it is progressing with big chunks every 10 seconds. I've thought of a way to make it faster. Could you look into it and maybe propose some changes to polish it.
- I've moved
setElement("np-" + ch + "-song-progress"
to be updated only when Song Hash ID changes (like the rest of elements) - Added this code. So it takes "elapsed" and adds 1 second to it every second until Song ID Changes and it starts over:
setElement("np-" + ch + "-song-progress", null, {
attrib: {
"max": np.now_playing.duration,
"title": "Total Duration: " + minSec(np.now_playing.duration) + " | Don't click! You'll achieve nothing by that:)"
}});
const track_update_interval = setInterval(updateProgress, 1000);
var elapsed = np.now_playing.elapsed;
function updateProgress() {
elapsed +=1; // add time every second
setElement("np-" + ch + "-song-progress", null, {
attrib: {
"value": elapsed,
}});
if (np && (np.now_playing.sh_id !== subs[station]["last_sh_id"])) {
clearInterval(track_update_interval);
}
};
Works quiet fine as far as I can see.
I didn’t check Buster’s code but he must be doing something like that in the official player, too. Maybe I should play with it… only it is included in almost every page of my website, so I fear the overhead a little. (Just because I’m lazy. So for instance, if someone changes the station description or name or such, it will automatically reflect on my website. Or if links change.)
Nice idea, in any case. Looks real smooth! Do you check for value
exceeding max
? Don’t know if all browsers would handle that gracefully.
Btw, I still like moment a lot, but it’s gotten a bit rusty, and most devs (like Buster) prefer Luxon nowadays. Luxon, as far as I know, is also used within AzuraCast.
Did you check out my latest update already? Introduces new station time stuff which should work everywhere on the world. ;-)
Did you check out my latest update already? Introduces new station time stuff which should work everywhere on the world. ;-)
Just checked. For me it retuns UTC+0 time as station time (and my station is UTC+3). Tried to pastte "niteradio" main url in my script tto show your station time for test - it shows again UTC+0 (But should UTC+1)
UPD. Oh silly of me! I have to edit "station:radio": {timezone: "Etc/UTC"}," ) to Etc/GMT-3
:)
Works great! Thanks @Moonbase59 !
(Just because I’m lazy. So for instance, if someone changes the station description or name or such, it will automatically reflect on my website. Or if links change.)
I didn't get it)
I tried to say I fear "overloading" this with all the extras, because the script is on each of my webpages. And thus it uses resources. And the reason it is on all my (station) web pages is that I’m lazy. I don’t want to change things on my webpage when the script can deliver the same data directly from the AzuraCast server. (Like station names and descriptions, links, etc.)
The timezones for you and me should probably be Europe/Moscow
and Europe/Berlin
. That’s what I tested with. Just use the names from the IANA list. (So India would be Asia/Kolkata
—good for testing because it has a half-hour offset.)
With "station:radio": {timezone: "Etc/UTC"}
we specify the timezone per station (the same AzuraCast server can have many stations in different timezones). This will be replaced by data from the API, once we get it.
Yeah, Europe/Moscow works as well.
Do you check for value exceeding max? Don’t know if all browsers would handle that gracefully.
This must happen everytime when jingles play. But I haven’t noticed anything unusual yet.
The idea of self-updating "smooth" progress bars is actually nice. Thanks for the idea.
Your code will fail awfully if you have more than one station, though. I will smoothen it out a little and use the subscribers (subs) object to store elapsed, duration and the ID of the updateProgressBar interval.
Updates, as usual, on the live demo page, and in my Gist. The "Winterzeit ist Radiozeit" page on my website will show 5 stations updating in parallel (hopefully).
Sounds nice!
Your code will fail awfully if you have more than one station
I've tried with two stations. I think it worked well.
But I'm glad that you've decided to give it a try! I'm sure it would work better:)
Replied in discord.
Up & running! Could you test with your full-width progress bar, please?
Thanks! Tried updated code, it is smooth, works very nice. 👍 Changed "title": "Total Duration: " + minSec(subs[station]["duration"])
since when it is elapsed
in there - it is flickering every second if you hover and it's kinda disturbing)
Thanks!
Nope, you are right. However wait, let me think. No, I am right. Everything works. Started questioning myself for a second.
Everything makes sense:
getTimezoneOffset
always returns difference between UTC+0 and your local time. In order to get difference between station time and listeners time we only need to add or subtract the difference between UTC+0 (getTimezoneOffset
) and our station.e.g. your station is UTC+1. getTimezoneOffset + 60 is your hardcoded difference. It will return 0 for you. It will return -120 for me because for me getTimeOffset is -180 plus 60 equals -120. If I am in New York it will return 360 (UTC-5 for New York + 60 minutes of your station difference against UTC-0).