Skip to content

Instantly share code, notes, and snippets.

@Moonbase59
Last active March 21, 2024 21:59
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save Moonbase59/d42f411e10aff6dc58694699010307aa to your computer and use it in GitHub Desktop.
Save Moonbase59/d42f411e10aff6dc58694699010307aa to your computer and use it in GitHub Desktop.
AzuraCast HPNP (High-Performance Now Playing) example for station websites, using SSE (Server-Sent Events)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Server-sent events demo (Centrifugo)</title>
<!-- style the indicators: -isonline, -islive, isrequest, progress bar -->
<style>
.label { border-radius: 0.1rem; padding: .1rem .2rem; background: #f0f1f4; color: #5b657a; display: inline-block; }
.label.label-success { background: #32b643; color: #fff; }
.label.label-error { background: #e85600; color: #fff; }
.text-ellipsis { width: 100%; padding-right: 1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* song progress overlay, cross-browser styling of the <progress> element */
progress {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
height: 4px;
width: 75px;
position: absolute;
bottom: 0%;
left: 0%;
border: none;
color: #2196f3;
opacity: 0.75;
background: #ebebeb; /* Firefox: unfilled portion */
font-size: 0.625em;
}
progress::-moz-progress-bar {
background: currentColor; /* Firefox: Filled portion */
}
progress::-webkit-progress-bar {
background: #ebebeb; /* Chrome/Safari: Unfilled portion */
}
progress::-webkit-progress-value {
background: currentColor; /* Chrome/Safari: Filled portion */
}
/* simple progressbar (just a <div>), using width % */
.progressbar {
height: 4px;
background-color: #2196f3;
transition: width 1s linear; /* make smoother */
}
</style>
</head>
<body>
<h1>Server-sent events (SSE) demo (Centrifugo)</h1>
<!-- Some station data -->
<h2 class="np-azuratest-radio-station-name">Station Name</h2>
<h3 class="np-azuratest-radio-station-description"></h3>
<p>Station time: <span class="np-azuratest-radio-station-time"></span>
(Timezone "<span class="np-azuratest-radio-station-timezone"></span>",
<span class="np-azuratest-radio-station-timediff-hhmm"></span> hours or
<span class="np-azuratest-radio-station-timediff-minutes"></span> minutes time difference)
</p>
<!-- Show time -->
<p>Playing at <span class="np-local-time"></span> your time
(<span class="np-local-timezone-long"></span>,
<span class="np-local-timezone-short"></span>):</p>
<!-- Putting it all together: Now Playing box with clickable album art & progress bar -->
<div style="display: flex; align-items: center; justify-content: start; background: #eee">
<div style="position: relative;">
<a class="np-azuratest-radio-station-player" href="https://example.com" target="_blank" title=""
onclick="window.open(this.href,'playerWindow',
'toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=640,height=360');
return false;">
<img class="np-azuratest-radio-song-albumart" alt="Albumcover" src="" width=75 style="float:left; margin-right: 1em;" />
</a>
<!-- progress bar on top of the image, see CSS styling -->
<progress class="np-azuratest-radio-song-progress"></progress>
</div>
<div class="text-ellipsis" style="text-align:left;">
<span class="np-azuratest-radio-show-name">Show</span>
<!-- add some indicators after the show name -->
<small><span class="np-azuratest-radio-show-islive label label-error">Live</span>
<span class="np-azuratest-radio-song-isrequest label label-success">Musikwunsch</span></small><br/>
<strong><span class="np-azuratest-radio-song-title">Titel</span></strong><br/>
<span class="np-azuratest-radio-song-artist">Interpret</span>
</div>
</div>
<!-- a simple full-width progress bar using percent values 0..100% -->
<div class="progressbar np-azuratest-radio-song-progressbar" role="progressbar"></div>
<p>The box above shows the <em>album art</em> (click to open a player popup), the <em>show</em> playing (with <em>Live</em> and <em>Song Request</em> indicators), the <em>song title</em> and <em>artist</em>. The <em>show name</em> is either the name of the current <em>playlist</em> playing, <em>»Live: Streamer Name«</em>, or the <em>song request</em> indicator if the current song was a listener request. It will also display the <em>Offline</em> indicator when the station goes offline.</p>
<p>We also deliver <code>value</code> and <code>max</code> data for a freely stylable <em>playing progress bar</em> using the HTML <code>&lt;progress&gt;</code> element. It is shown here at the bottom of the album cover. Hovering the mouse pointer over it shows elapsed playing time. A simple progressbar using width percentage values can also be had, shown beneath the grey box above.</p>
<!-- (Re-)using variables in running text. -->
<p>The station is <span class="np-azuratest-radio-station-isonline label"></span> and plays a <span class="np-azuratest-radio-song-duration"></span> song from <span class="np-azuratest-radio-song-artist"></span>’s album »<span class="np-azuratest-radio-song-album"></span>«.</p>
<!-- Examples for popup Audio and Video Player links -->
<p>Simple <em>links</em> for an
<a class="np-azuratest-radio-station-player" href="https://example.com" target="_blank" title=""
onclick="window.open(this.href,'playerWindow',
'toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=640,height=360');
return false;">audio</a>
<a class="np-azuratest-radio-video-player" href="https://example.com" target="_blank" title=""
onclick="window.open(this.href,'playerWindow',
'toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=640,height=360');
return false;">or video</a>
player are also possible. (The video player link only works if your station actually <em>provides</em> a video stream.)
</p>
<!-- Example of using data from multiple stations on the same page -->
<p>If your radio has <em>multiple stations</em>, you can show data for the other stations as well:</p>
<ul>
<!-- Station shortcode: azuratest_radio -->
<li><strong><span class="np-azuratest-radio-station-name"></span></strong>
(<span class="np-azuratest-radio-station-listeners-total"></span> listeners):
<span class="np-azuratest-radio-song-text"></span></li>
<!-- Station shortcode: other-station -->
<!--
<li><strong><span class="np-other-station-station-name"></span></strong>:
<span class="np-other-station-song-text"></span></li>
-->
<!-- Station shortcode: third-station -->
<!--
<li><strong><span class="np-third-station-station-name"></span></strong>:
<span class="np-third-station-song-text"></span></li>
-->
</ul>
<!-- Some info -->
<p>This example uses the <em>AzuraCast Centrifugo Now-Playing</em> API
and Server-Sent Events (SSE). Just include Moonbase59’s small
<a href="https://gist.github.com/Moonbase59/d42f411e10aff6dc58694699010307aa">
<code>sse_cf.js</code></a>
Javascript at the bottom of your HTML <code>&lt;body&gt;</code> and you’re all set.
No extra frameworks (like <em>jQuery</em> and the like) required.
</p>
<p><em>Note:</em> When using this in a production environment, I recommend using a <em>minifier</em> after testing everything out—it will make the Javascript much smaller and faster to load.</p>
<!-- Include the JS at the end of the body. No frameqorks like jQuery required. -->
<script src="sse_cf_demo.js"></script>
</body>
</html>
// sse_cf_demo.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
// 2024-02-05 Moonbase59 - Add station listener counts.
//
// 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_cf_demo.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 = "";
// station base URL
const baseUri = "https://demo.azuracast.com";
// 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:azuratest_radio": {timezone: "Etc/UTC"},
//"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);
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);
// Need to use Math.trunc instead of Math.round,
// to avoid results like "3:60" being returned on times like 239.51 seconds
const seconds = Math.trunc(duration % 60);
return `${String(minutes)}:${String(seconds).padStart(2, '0')}`;
}
// 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, 1000, 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
// allow elapsed (seconds) to be float (more exact)
subs[station]["elapsed"] += (now - subs[station]["last_update"]) / 1000;
subs[station]["last_update"] = now;
// 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 <progress> progress bar element on page
setElement("np-" + ch + "-song-progress", null, {
attrib: {
"value": subs[station]["elapsed"],
"max": subs[station]["duration"],
"title": minSec(subs[station]["elapsed"])
}});
// 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;
//console.log(np.now_playing.sh_id, subs[station]["last_sh_id"]);
// 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));
// station listener counts
setElement("np-" + ch + "-station-listeners-total", String(np.listeners.total));
setElement("np-" + ch + "-station-listeners-unique", String(np.listeners.unique));
setElement("np-" + ch + "-station-listeners-current", String(np.listeners.current));
};
// 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-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
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 + "-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"]
});
} 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.
// Occurs roughly every minute.
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
Author

Moonbase59 commented Dec 8, 2023

Example for dynamic webpages, using AzuraCast’s HPNP (High-Performance Now Playing) API and SSE (Server-Sent Events)

Note: It appears that GitHub image uploads are broken. The originals are lost and you’ll see some "Access Denied" messages. Sorry for that, go complain at GitHub. For the time being, I set up a live demo.

This documentation is current as of 2024-02-05.

Installation

  1. Copy both files to your webspace and try them out.
  2. Make a copy of sse_cf_demo.js, name it sse_cf.js and put it into your website’s Javascript assets folder.
  3. Edit to adapt for your station(s):
    1. const baseUri … – your AzuraCast server’s base URL
    2. let subs … – put the station shortcodes you wish to subscribe to here. Don’t forget the timezone per station if running AzuraCast older than Rolling Release 6b511b0 (2024-01-29).
    3. const autoplay … – set autoplay on or off (works on same scheme/domain only)
    4. const t … – Put your own translations here. Modify only the values at the right (past the colon :)!
    5. const video_player_url … – If your station has a video player page, add its URL here.
  4. The class names to use in your HTML follow the scheme np-<shortcode>-item-subitem. Replace <shortcode> with the shortcode(s) of your station(s). You can try this out using the provided sse_cf_demo.html: Simply replace all np-azuratest-radio in the file with your shortcode np-<shortcode> and view the file to check if everything works.
  5. I recommend including the script near the end of your HTML <body>. We must wait until the DOM ready state is complete anyway before starting dynamic updates and modifying elements dynamically. (You could try interactive but I found this will not work reliably with many CMS’es, so I went for "safe".)
  6. After setting everything up and making changes according to your needs, I recommend to use a Javascript minifier for production environments. This will make the Javascript much more compact and remove any comments, so it will load faster. But keep the original, so you can make changes more easily!

It should look like this:

Server-sent events demo (Centrifugo) — Mozilla Firefox_001

In a real website, it might later look like this (as an example):

Auswahl_330

Now Playing data provided

Let’s assume our station shortcode is azuratest_radio, as in the example. The script will convert station shortcodes to names that are valid as classnames, so this will become azuratest-radio (so-called "Kebab Case").

We then get the following classes to work with:

Local data (on listener’s end)

  • np-local-time (user time, HH:MM format)
  • np-local-timezone-short (user timezone, short format, i.e. CET, GMT+1)
  • np-local-timezone-long (user timezone, long format, i.e. Central European Standard Time)

Station data

  • np-azuratest-radio-station-name
  • np-azuratest-radio-station-description
  • np-azuratest-radio-station-url
  • np-azuratest-radio-station-player-url (the URL of the public player)
  • np-azuratest-radio-station-player (sets href and target for an onclick popup player)
  • np-azuratest-radio-video-player-url (URL of your video player, if you have a video stream)
  • np-azuratest-radio-video-player (sets href and target for an onclick popup video player)
  • np-azuratest-radio-station-isonline (sets "Online"/"Offline" text and label-success/label-error classes on element)
  • np-azuratest-radio-station-time (HH:MM)
  • np-azuratest-radio-station-timezone (examples: Etc/UTC (the default), Asia/Kolkata)
  • np-azuratest-radio-station-timediff-hhmm (±HH:MM time difference between listener location and station*)
  • np-azuratest-radio-station-timediff-minutes (minutes time difference between listener location and station*)
  • np-azuratest-radio-station-listeners-total
  • np-azuratest-radio-station-listeners-unique
  • np-azuratest-radio-station-listeners-current

* = These can be used to explain why the "Schedule" iframe always shows station time, not user local time.

Show data

  • np-azuratest-radio-show-name (playlist name, "Live: Streamer Name", or "Offline" indicator)
  • np-azuratest-radio-show-islive (sets display style to inline/none on the "Live" indicator element)

Song data

  • np-azuratest-radio-song-artist
  • np-azuratest-radio-song-title
  • np-azuratest-radio-song-text ("Artist - Title" string)
  • np-azuratest-radio-song-album
  • np-azuratest-radio-song-albumart (sets <img> attributes)
  • np-azuratest-radio-song-isrequest (sets display style to inline/none on the "Listener Request" indicator element)
  • np-azuratest-radio-song-progress (sets value, max, title on HTML <progress> element)
  • np-azuratest-radio-song-progressbar (sets width % on simple <div>-based progress bars)
  • np-azuratest-radio-song-duration (MM:SS)
  • np-azuratest-radio-song-elapsed (MM:SS; won’t overrun duration, except on live transmissions, where duration is zero)

Note I had to find some means to differentiate stations, in case you want to display data for more than one station on the same web page, like in the screenshot above. This is the main reason to include the station shortcode in the class names.

Also note that we use class="…" on the HTML elements we want to dynamically update, not id="…" as you might have seen elsewhere. The advantage is that you can use the same dynamic element multiple times on the same page, which would not work when using id.

Update frequency and special cases

  • Song playing progress bar data will be updated roughly every second. Once we have the song’s duration and current playing point, this happens locally and doesn’t hit the server connection.
  • Station time and timezone data as well as song elapsed time will be updated/re-synchronized on every SSE event. This currently happens roughly every 10 seconds (as of January, 2024).
  • All other data (station, show, song and album cover data) will only be updated when the song hash id changes (i.e., a new song plays). This avoids unnecessary overhead, since we already have many elements to dynamically update a station web page with.
  • The script handles song skipping and the use of blank.skip in your Liquidsoap config gracefully and will adjust times and progress bars.
  • The script handles sleep modes gracefully. So if you close the lid of your laptop and have it go to sleep mode, it will automatically resume updating without manual intervention when you’re back online.
  • In case of an unexpected server loss (connection problems, etc.), the script will immediately stop local time & progress updating and show the affected station(s) as Offline. It will show the last metadata though, so you know where and when the error happened. After correcting the problem, just refresh the web page (F5).

Styling

Check the HTML example for styling. The code uses a very few styles for the Online/Live/Request indicators. Add or change these as needed.

Classes used:

  • label – general style of the indicators
  • label-success – style for "online" and "request" (green; used together with label)
  • label-error – style for "offline" and "live" (red; used together with label
  • text-ellipsis – handles overflow for long show/title/artist names
  • progress– cross-browser style for playing progress bar ( <progress> element)
  • progressbar – style for simple progressbar-type <div>

Known problems

  • When jingles from a playlist with suppressed metadata play between songs, the progress bar might shortly "jitter" a little at the start of the next song after the jingle. This is due to readjusting the "seconds elapsed" value coming from the SSE updates.
  • When a jingle from an interrupting playlist plays over a long song (say a top-of-the-hour station ID), both the official web player’s and my progress bars will reset to 0:00 elapsed time but still show the full song length. When the song really ends, it will then look like it was ending "somewhere in the middle" of the progress bar shown. This is due to a metadata replay happening after the jingle has played, but should hopefully not be much of an issue. (See also AzuraCast/AzuraCast#6909.)

Have fun!

You can create beautiful dynamic websites using this—the sky is the limit. The code used is fast, low overhead, reliable and tested on many different browsers like Firefox, Chrome, Chromium, Opera, Safari, Edge on Linux, macOS, Windows and Android smart devices.

Thanks to @BusterNeece for working with me on all the AzuraCast HPNP improvements!
Also thanks to @gAlleb who brought up many interesting ideas and also uses this.

@gAlleb
Copy link

gAlleb commented Dec 14, 2023

@Moonbase59, good job! 👍 Thank you for your work!

@gAlleb
Copy link

gAlleb commented Jan 12, 2024

@Moonbase59 Hi! I've been struggling with an issue. Could you help?

I've added some code to your script to show when tracks have been played. And it's ok with getTimeFromTimestamp. Then I've thought to add a function that shows time ago (you can see it here with function time2TimeAgo https://gist.github.com/gAlleb/cd241f521e7aad22634c3301b8643352)

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 + "-song-history-1-played-ago",time2TimeAgo(np.song_history[0].played_at));

Issue is that according to script time2TimeAgo(np.song_history[0].played_at) is updated on track change (well, sure it is :)) and waits until next track.
How to apply and make time2TimeAgo(np.song_history[0].played_at) be updated every minute so it shows relevant time like the default public player in Song History in Azuracast instance ?

Thanks) Have been struggling)

@Moonbase59
Copy link
Author

@gAlleb Have to look into that. I purposely defy page updates as long as the song hash ID doesn’t change, since finding many elements on a page by classname is resource-hungry. You could try experimenting by commenting that test out maybe, since Centrifugo will update the meta quite often. Let me know your outcome!

It’s here: https://gist.github.com/Moonbase59/d42f411e10aff6dc58694699010307aa#file-sse_cf_demo-js-L163

Updating more often would also be needed if wanting to show some duration progress bar or the like. I’m thinking we’d maybe have to cache the found elements, so a new search in the DOM could be avoided. That would require some more code changes, though.

@gAlleb
Copy link

gAlleb commented Jan 19, 2024

Thanks @Moonbase59 ! Commenting out works fine. Now it is the same as in public player. As far as I can measure updates are every 8 seconds. But yeah finding everytime so many elements should be resource-hungry. And I've already got a ton of them :)

I uncommented the if argument again and just moved this section before it. Now everything is updated on the song hash ID change. And 5 elements with time_ago function are updated every 8 seconds.

Now it looks like this:

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;
  //console.log(np.now_playing.sh_id, subs[station]["last_sh_id"]);

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

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

https://gist.github.com/gAlleb/cd241f521e7aad22634c3301b8643352

@dekakast
Copy link

@Moonbase59 Hi Matthias, are you available for a small paid project to implement this on my server?

@Moonbase59
Copy link
Author

Hi @dekakast, I always thought the above docs are quite self-explanatory: Just put the renamed and changed sse_cf.js in a feasible place on your website, like /assets/js or the like, include it on the pages on which you’ll need it, and start using the classes in your HTML code (or CMS, if it allows), like in

<span class="np-azuratest-radio-song-title"></span>

Obviously, the azuratest-radio part in the class name needs to be changed to the "kebab-cased" version of your station shortcode.

I’m no "frontend man", so I won’ŧ do any fancy design stuff, and I’m not too enthusiastic about Wordpress or Windows either (haven’t used those for far more than a decade), so probably no help there. Nevertheless, I’m currently sometimes in the Discord (same name), and you can try to reach out to me there. Anydesk available for short sessions, and Discord or my Nextcloud server for talking.

@dekakast
Copy link

dekakast commented Feb 18, 2024

Hi @Moonbase59, thank you for replying. I can get the now playing info to display using the Standard Now Playing API, but I cannot get SSE to work with the code below. Obviously, I change the baseUri, short_name, and radio.mp3.

In System Settings, I checked the box to "Use High-Performance Now Playing Updates" but it took all my stations offline. I turned it off and the stations came back online. The Standard Now Playing API still works with box unchecked. I am guessing I have to check the box again to turn on SSE, but it will just take my stations offline again, and I still won't get any SSE now playing info.

I would greatly appreciate any suggestions :)

<div class="album-container">
    <img id="albumArt" src="" alt="Album Art">
    <img id="playButton" src="https://path/to/play.png" alt="Play" class="control-button">
    <div id="loadingSpinner" class="loading-spinner"></div>
</div>

<div class="info" id="title">Title</div>
<div class="info" id="artist">Artist</div>
<div class="info" id="liveStatus"></div>

<script>
$(document).ready(function() {
    var audio = new Audio();
    audio.preload = "auto";

    // Base URL and station subscription configuration for SSE
    const baseUri = "https://demo.azuracast.com";
    let subs = {
        "station:short_name": {timezone: "Chicago/UTC-6"},
        "global:time": {}
    };
    const sseUri = baseUri + "/api/live/nowplaying/sse?cf_connect="+JSON.stringify({"subs": subs});

    // Initialize subscribers
    Object.keys(subs).forEach((station) => {
        subs[station]["nowplaying"] = null;
        subs[station]["last_sh_id"] = null;
    });

    // SSE Event Handling
    let evtSource = new EventSource(sseUri);

    evtSource.onmessage = function(event) {
        const data = JSON.parse(event.data);
        if (data.station && data.station.shortcode === 'short_name') {
            updateNowPlaying(data.nowplaying);
        }
    };

    evtSource.onerror = function() {
        console.error("Error with SSE");
    };

    function updateNowPlaying(np) {
        $('#albumArt').attr('src', np.song.art || 'path/to/fallback/image.png');
        $('#title').text('"' + (np.song.title || 'Unknown Title') + '"');
        $('#artist').text(np.song.artist || 'Unknown Artist');
        if (np.live && np.live.is_live) {
            $('#liveStatus').html('<span class="badge-important">Live: ' + np.live.streamer_name + '</span>');
        } else {
            $('#liveStatus').empty();
        }
        // Update the audio src only if it has changed
        var newSrc = np.song.mp3_url || 'https://path/to/radio.mp3';
        if (audio.src !== newSrc) {
            audio.src = newSrc;
        }
    }

    // Existing code for handling play/pause actions and audio events
    function updatePlayState(isPlaying) {
        if (isPlaying) {
            $('.album-container').addClass('playing');
            $('#playButton').hide();
            $('#loadingSpinner').hide();
        } else {
            $('.album-container').removeClass('playing');
            audio.currentTime = 0;
            $('#playButton').show();
        }
    }

    $('#playButton, #albumArt').click(function() {
        if (audio.src && audio.paused) {
            audio.play();
            $('#loadingSpinner').show();
        } else if (audio.src && !audio.paused) {
            audio.pause();
        }
    });

    audio.addEventListener('play', function() {
        updatePlayState(true);
    });

    audio.addEventListener('pause', function() {
        updatePlayState(false);
    });

    audio.addEventListener('canplaythrough', function() {
        $('#loadingSpinner').hide();
    });

    audio.addEventListener('waiting', function() {
        $('#loadingSpinner').show();
    });

    audio.addEventListener('error', function() {
        console.error('Audio playback error');
        $('#loadingSpinner').hide();
    });
});
</script>

@Moonbase59
Copy link
Author

@dekakast This normally works without problems. Maybe a setup/AzuraCast/CloudFlare/Nginx Proxy problem? Suggest you open an issue/a discussion over at AzuraCast to ensure there’s nothing wrong with the setup. Enabling "High-Performance Now Playing Updates" shouldn’t make stations go offline.

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