-
-
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 | |
})(); |
@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.
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 :)