Skip to content

Instantly share code, notes, and snippets.

@BusterNeece
Forked from Moonbase59/sse_cf_demo.html
Created December 14, 2023 21:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save BusterNeece/4026110b4aa13ab7be5407f466792003 to your computer and use it in GitHub Desktop.
Save BusterNeece/4026110b4aa13ab7be5407f466792003 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 -->
<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 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</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>
<!-- Putting it all together: Now Playing box with clickable album art -->
<div style="display: flex; align-items: center; justify-content: start; background: #eee">
<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>
<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>
<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>
<!-- (Re-)using variables in running text. -->
<p>The station is <span class="np-azuratest-radio-station-isonline label"></span> and plays a 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-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>
<!-- Include the JS at the end of the body. No frameqorks like jQuery required. -->
<script src="sse_cf_demo.js"></script>
</body>
</html>
// 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
//
// 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 = "";
// 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"
let subs = {
"station:azuratest_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;
});
// 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 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);
}
// 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, 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);
});
}
// remove Classes, if any
if (targ && removeClasses) {
targ.classList.remove(...removeClasses);
}
// add Classes, if any
if (targ && addClasses) {
targ.classList.add(...addClasses);
}
});
}
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"]);
// 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);
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"]
});
}
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"]
});
}
}
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);
// 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
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment