-
-
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)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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><body></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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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