Skip to content

Instantly share code, notes, and snippets.

@gAlleb
Forked from Moonbase59/sse_cf_demo.html
Last active May 27, 2024 02:39
Show Gist options
  • Save gAlleb/cd241f521e7aad22634c3301b8643352 to your computer and use it in GitHub Desktop.
Save gAlleb/cd241f521e7aad22634c3301b8643352 to your computer and use it in GitHub Desktop.
AzuraCast HPNP (High-Performance Now Playing) example for station websites, using SSE (Server-Sent Events)
// 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
})();
@Moonbase59
Copy link

Moonbase59 commented Jan 26, 2024

Interesting fact: The schedule shows station time but the red "pointer" (we are here) points to the correct playlist time. So even if it was already 10:00 your time, it shows 8:00 on the station. So at least the correct currently playing show is indicated!

I’m thinking of adding something like a np-server-time and maybe even some progress indicator to the script, how do you feel about that? Since (with HPNP enabled) we’re getting a time signal roughly every minute, and NP updates roughly every 10s, it might be nice to (separately) update an HH:MM server time and (per station) progress indicator, say a small white line at the bottom of the album cover……

We could use getTimeFromTimestamp(timestamp) to display the times, I’m thinking.

@Moonbase59
Copy link

Moonbase59 commented Jan 26, 2024

I introduced (but not yet published)

  • np-global-time (coming from the AzuraCast timestamps)
  • np-global-timezone-short, i.e. GMT+3
  • np-global-timezone-long, i.e. Москва, стандартное время

Time is always user local time, timezones displayed in user’s browser language. Just in case.

We might want to ask @BusterNeece to include the station timezone (ex: Europe/Berlin) in the /nowplaying, /nowplaying/{station_id}, /stations and /station/{station_id} API endpoints, since they can be different from station to station (think resellers). With this, we could recalculate and either show listeners a time difference (schedule!) or at least state something like "Times shown are Central European Standard Time, GMT+1").

EDIT: Added a feature request.

Try https://grav1.niteradio.net/wh/sse_cf_demo.html to see if this works on your end so far.

On Linux, in a terminal, you can, for instance, use

TZ=Europe/Moscow chromium

to start up a browser in a timezone different from your own.

grafik

Hmm. Seems I can’t add a screenshot here anymore.

@gAlleb
Copy link
Author

gAlleb commented Jan 26, 2024

It's a great idea!

and (per station) progress indicator, say a small white line at the bottom of the album cover……

It sounds nice)

we could recalculate and either show listeners a time difference (schedule!)

Not sure what you mean here. We can change schedule page to accomodate listeners's local time? If so - it would be great but I can't imagine a way of doing this)

@gAlleb
Copy link
Author

gAlleb commented Jan 26, 2024

Yea, it works)

In mumbai))
Screenshot 2024-01-26 142814
Screenshot 2024-01-26 142731

@Moonbase59
Copy link

Moonbase59 commented Jan 26, 2024

That’s good to know, thanks for testing! Can you wait a little longer, maybe until we get the station timezone as per feature request, or should I publish the "intermediate" in my Gist?

We can change schedule page to accomodate listeners's local time?

No, unfortunately not. That would be something the AzuraCast dev’s must do (if they want). Since I included the schedule in an iframe in my page, I was thinking of at least showing the listeners something like "Times shown are (timezone info)" or "Times shown differ by +6 hours from your local time" or the like.

P.S.: In the browser console, you can already see the potential progress bar data (elapsed/duration in s).

@gAlleb
Copy link
Author

gAlleb commented Jan 26, 2024

Oh, I see.

Can you wait a little longer, maybe until we get the station timezone as per feature request, or should I publish the "intermediate" in my Gist?

As it suits you :) I can wait

@Moonbase59
Copy link

First version with a mini progress bar, as yet unstyled.

@gAlleb
Copy link
Author

gAlleb commented Jan 26, 2024

Oh, Looks nice!

Been thinking how to show Times shown are (timezone info)" or "Times shown differ by +6 hours from your local time until we got the feature.

Came up with something like (dirty)

function tz_difference() {
      //Getting local listener time
      const date = new Date();
      // Getting Timezone offset in minutes
      let tz_difference= date.getTimezoneOffset();
      // Since I have UTC+3 = plus 180 minutes
      let my_tz_difference = Math.floor(tz_difference+180);
     // Divide by 60 :) but it eliminates “minus”))
      document.getElementById('time_difference').innerHTML = Math.floor(my_tz_difference/60);
    }

@Moonbase59
Copy link

Moonbase59 commented Jan 26, 2024

Yeah, but if we don’t hardcode our station timezone, we’ll never know the difference to the schedule :-(

I love unobtrusive: Look again (maybe zoom) at the lower edge of the album art. You can also try to hover over the little bar. ;-)
It’s astonishing that all browsers haven’t gotten their progress color right, grr. accent-color only works when you don’t remove the border.

I give you an np-station-song-progress to be used on a <progress> element, which fills in value, max and title. It can be put anywhere and styled as you want. What do you think?

@gAlleb
Copy link
Author

gAlleb commented Jan 26, 2024

Looks great! It is very neat!

Regarding time zone. Nothing to be hardcoded here cause 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 and our station.

@gAlleb
Copy link
Author

gAlleb commented Jan 26, 2024

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

@Moonbase59
Copy link

Moonbase59 commented Jan 26, 2024

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.

@Moonbase59
Copy link

Moonbase59 commented Jan 26, 2024

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?

@gAlleb
Copy link
Author

gAlleb commented Jan 26, 2024

I switched one testing station to Moscow time, could you please check its schedule?

Yeap. Shows correctly.

On iOS works fine:
Screenshot 2024-01-26 21295

On Windows and MacOS too:) Sorry) Couldn't wait)
Screenshot 2024-01-26 212958

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).";
      }
    }
Screenshot 2024-01-26 213242 Screenshot 2024-01-26 213156

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

Screenshot 2024-01-26 214421 Screenshot 2024-01-26 214358

@gAlleb
Copy link
Author

gAlleb commented Jan 26, 2024

Screenshot 2024-01-26 214823

Shows great as well!

Sometimes API fails and bar is always full until next track. Hm)

Sent a request in discord.

@gAlleb
Copy link
Author

gAlleb commented Jan 26, 2024

on iOS works well
photo_2024-01-26_22-12-03

@Moonbase59
Copy link

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

@gAlleb
Copy link
Author

gAlleb commented Jan 26, 2024

Great job with progress bar! Thanks!

@Moonbase59
Copy link

Well, "together" is always better! :-) Guess I’ll put the new code in the Gist tomorrow.

@gAlleb
Copy link
Author

gAlleb commented Jan 26, 2024

Yeah, great idea! I'm already using it and loving it ;)

@Moonbase59
Copy link

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…

@gAlleb
Copy link
Author

gAlleb commented Jan 27, 2024

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.

  1. I've moved setElement("np-" + ch + "-song-progress" to be updated only when Song Hash ID changes (like the rest of elements)
  2. 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.

@Moonbase59
Copy link

Moonbase59 commented Jan 27, 2024

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. ;-)

@gAlleb
Copy link
Author

gAlleb commented Jan 27, 2024

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)

@Moonbase59
Copy link

Moonbase59 commented Jan 27, 2024

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

Arbeitsfläche 1_046

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.

@gAlleb
Copy link
Author

gAlleb commented Jan 27, 2024

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.

@Moonbase59
Copy link

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

@gAlleb
Copy link
Author

gAlleb commented Jan 27, 2024

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.

@Moonbase59
Copy link

Up & running! Could you test with your full-width progress bar, please?

@gAlleb
Copy link
Author

gAlleb commented Jan 28, 2024

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment