-
-
Save Moonbase59/d42f411e10aff6dc58694699010307aa to your computer and use it in GitHub Desktop.
<!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> | |
<audio class="np-azuratest-radio-station-stream" controls src=""></audio> | |
<small>Stream: <span class="np-azuratest-radio-station-stream-url"></span></small> | |
<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><progress></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> | |
<p>The little audio player beneath shows you can also use a simple HTML5 <code><audio></code> element instead of a full-fledged audio player. It also demonstrates our <em>MediaSession</em> support. Unlike normal browser behaviour, the audio will <strong>stop streaming</strong> when you use the Pause/Stop button. This is to keep RAM usage low and stop wasting money on paid volume contracts. You’d also want to hear <em>what’s shown</em> when resuming, not what played possibly hours earlier.</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><body></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. | |
// 2024-05-22 Moonbase59 - Update for latest Centrifugo, remove global:time, add caching | |
// 2024-05-26 PeWe79/Moonbase59 - Add "beforeunload" event handler, see notes. | |
// 2024-06-25 Moonbase59 - Add default stream URL and mediaSession support. | |
// 2024-06-29 Moonbase59 - Really stop streaming on pausing an audio element | |
// - Try to make Play from mediaSession possible - only Firefox | |
// | |
// 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": {recover: true, timezone: "Etc/UTC"}, | |
let subs = { | |
"station:azuratest_radio": {recover: true, timezone: "Etc/UTC"}, | |
//"station:other-station": {recover: true}, | |
//"station:third-station": {recover: true} | |
}; | |
// 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 | |
subs[station]["stream_url"] = ""; // saved default stream URL | |
}); | |
// 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 | |
} | |
} | |
// return station default stream URL | |
function getDefaultStream(station) { | |
let data = subs[station]?.nowplaying?.station || undefined; | |
let mounts = data?.mounts || undefined; | |
let defaultMount = mounts.filter(item => item.is_default); | |
if (data.hls_enabled && data.hls_is_default) { | |
return data.hls_url; | |
} else { | |
if (defaultMount.length > 0) { | |
return defaultMount[0].url; | |
} | |
} | |
return null; | |
} | |
// 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", ""); | |
} | |
// Set default stream URL | |
// Note: Changing src attribute in audio element will force the player | |
// to reset and stop. Only update when URL changes. | |
defaultStream = getDefaultStream(station); | |
// console.log(defaultStream); | |
if (subs[station]["stream_url"] !== defaultStream) { | |
setElement("np-" + ch + "-station-stream-url", defaultStream); | |
// Stop browser from "pausing" a stream and continue downloading, | |
// thus wasting mobile user’s download contingent. | |
// Handle "Pause" like "Stop" and reconnect to stream on "Play". | |
setElement("np-" + ch + "-station-stream", null, { | |
attrib:{ | |
"src": defaultStream, | |
"preload": "none", | |
"controlslist": "nodownload", // makes no sense on a stream | |
// reset player to avoid endless buffering | |
// Firefox: Set "play" handler so playback can be resumed from mediaSession | |
"onpause": 'this.src=this.src;if ("mediaSession" in navigator) {navigator.mediaSession.setActionHandler("play", () => this.play())};' | |
}}); | |
subs[station]["stream_url"] = defaultStream; | |
} | |
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;"} | |
}); | |
} | |
// Show metadata and cover image in case mediaSession is supported. | |
if ("mediaSession" in navigator) { | |
navigator.mediaSession.metadata = new MediaMetadata({ | |
title: np.now_playing.song.title, | |
artist: np.now_playing.song.artist, | |
album: np.now_playing.song.album, | |
artwork: [{ | |
src: np.now_playing.song.art, | |
}] | |
}); | |
} | |
} | |
} | |
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.data; | |
// 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) { | |
const connectData = jsonData.connect; | |
if ("data" in connectData) { | |
// Legacy SSE data | |
connectData.data.forEach( | |
(initialRow) => handleData(initialRow) | |
); | |
} else { | |
// New Centrifugo cached NowPlaying initial push | |
for (const subName in connectData.subs) { | |
const sub = connectData.subs[subName]; | |
if ("publications" in sub && sub.publications.length > 0) { | |
sub.publications.forEach((initialRow) => handleData(initialRow)); | |
} | |
} | |
} | |
} else if ("pub" in jsonData) { | |
handleData(jsonData.pub); | |
} | |
} | |
} | |
} | |
// 2024-05-26 suggested by PeWe79 on: | |
// https://github.com/AzuraCast/AzuraCast/discussions/7138#discussioncomment-9559366 | |
// Note the `beforeunload` event suffers from some problems: | |
// - It is not reliably fired, especially on mobile platforms. | |
// - In Firefox, it’s not compatible with the back/forward cache, affects performance. | |
window.addEventListener("beforeunload", function () { | |
if (evtSource !== null) { | |
evtSource.close(); | |
evtSource = null; | |
} | |
}); | |
// 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, good job! 👍 Thank you for your work!
@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)
@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.
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
@Moonbase59 Hi Matthias, are you available for a small paid project to implement this on my server?
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.
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>
@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.
@PeWe79 suggested an improvement for not getting console errors when refreshing the page, and it’s now included. Thanks!
Hi @Moonbase59 thank you!.
@Moonbase59 Would you be so kind as to walk me through anything I need to do on my Azuracast installation to get SSE to work? I've tried it, and it connects, but it only seems to return {}
and nothing else, every 25 seconds. I'm running your code, just swapping in my station URL and the station name, and it's simply not working -- to be expected, if the JSON is empty!
I have verified that the SSE checkbox is on, and I'm streaming with Icecast.
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-06-29.
Installation
sse_cf_demo.js
, name itsse_cf.js
and put it into your website’s Javascript assets folder.const baseUri …
– your AzuraCast server’s base URLlet subs …
– put the station shortcodes you wish to subscribe to here. Don’t forget thetimezone
per station if running AzuraCast older than Rolling Release 6b511b0 (2024-01-29).const autoplay …
– set autoplay on or off (works on same scheme/domain only)const t …
– Put your own translations here. Modify only the values at the right (past the colon:
)!const video_player_url …
– If your station has a video player page, add its URL here.np-<shortcode>-item-subitem
. Replace<shortcode>
with the shortcode(s) of your station(s). You can try this out using the providedsse_cf_demo.html
: Simply replace allnp-azuratest-radio
in the file with your shortcodenp-<shortcode>
and view the file to check if everything works.<body>
. We must wait until the DOM ready state iscomplete
anyway before starting dynamic updates and modifying elements dynamically. (You could tryinteractive
but I found this will not work reliably with many CMS’es, so I went for "safe".)It should look like this:
In a real website, it might later look like this (as an example):
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 becomeazuratest-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
(setshref
andtarget
for anonclick
popup player)np-azuratest-radio-video-player-url
(URL of your video player, if you have a video stream)np-azuratest-radio-video-player
(setshref
andtarget
for anonclick
popup video player)np-azuratest-radio-station-isonline
(sets "Online"/"Offline" text andlabel-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
np-azuratest-radio-station-stream-url
(URL of the station’s default stream)np-azuratest-radio-station-stream
(sets attributes for a HTML5<audio>
element)* = 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 toinline
/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 toinline
/none
on the "Listener Request" indicator element)np-azuratest-radio-song-progress
(setsvalue
,max
,title
on HTML<progress>
element)np-azuratest-radio-song-progressbar
(setswidth %
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, notid="…"
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 usingid
.Update frequency and special cases
blank.skip
in your Liquidsoap config gracefully and will adjust times and progress bars.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 indicatorslabel-success
– style for "online" and "request" (green; used together withlabel
)label-error
– style for "offline" and "live" (red; used together withlabel
text-ellipsis
– handles overflow for long show/title/artist namesprogress
– cross-browser style for playing progress bar (<progress>
element)progressbar
– style for simple progressbar-type<div>
Preventing RAM/bandwidth abuse by the browser
Browsers keep streaming even when you hit the "Pause" or "Stop" buttons. This behaviour…
To avoid all this,
sse_cf_demo.js
forces the browser to stop streaming when Pause/Stop is used.Known problems
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.