diff --git a/public_html/scripts/m3u-player.js b/public_html/scripts/m3u-player.js
new file mode 100644
index 0000000..934cf27
--- /dev/null
+++ b/public_html/scripts/m3u-player.js
@@ -0,0 +1,315 @@
+// @license magnet:?xt=urn:btih:cf05388f2679ee054f2beb29a391d25f4e673ac3&dn=gpl-2.0.txt GPL-v2-or-Later
+// see: m3u-Playlist player in Vanilla Javascript (https://www.draketo.de/software/m3u-player.html)
+// last viewed: 2025-11-15
+const nodes = document.querySelectorAll("audio,video");
+const playlists = {};
+const prefetchedTracks = new Map(); // use a map for insertion order, so we can just blow away old entries.
+// maximum prefetched blobs that are kept.
+const MAX_PREFETCH_KEEP = 10;
+// maximum allowed number of entries in a playlist to prevent OOM attacks against the browser with self-referencing playlists
+const MAX_PLAYLIST_LENGTH = 1000;
+const PLAYLIST_MIME_TYPES = ["audio/x-mpegurl", "audio/mpegurl", "application/vnd.apple.mpegurl","application/mpegurl","application/x-mpegurl"];
+function stripUrlParameters(link) {
+ const url = new URL(link, window.location);
+ url.search = "";
+ url.hash = "";
+ return url.href;
+}
+function isPlaylist(link) {
+ const linkHref = stripUrlParameters(link);
+ return linkHref.endsWith(".m3u") || linkHref.endsWith(".m3u8");
+}
+function isBlob(link) {
+ return new URL(link, window.location).protocol == 'blob';
+}
+function parsePlaylist(textContent) {
+ return textContent.match(/^(?!#)(?!\s).*$/mg)
+ .filter(s => s); // filter removes empty strings
+}
+/**
+ * Download the given playlist, parse it, and store the tracks in the
+ * global playlists object using the url as key.
+ *
+ * Runs callback once the playlist downloaded successfully.
+ */
+function fetchPlaylist(url, onload, onerror) {
+ const playlistFetcher = new XMLHttpRequest();
+ playlistFetcher.open("GET", url, true);
+ playlistFetcher.responseType = "blob"; // to get a mime type
+ playlistFetcher.onload = () => {
+ if (PLAYLIST_MIME_TYPES.includes(playlistFetcher.response.type)) { // security check to ensure that filters have run
+ const reader = new FileReader();
+ const load = onload; // propagate to inner scope
+ reader.addEventListener("loadend", e => {
+ playlists[url] = parsePlaylist(reader.result);
+ onload();
+ });
+ reader.readAsText(playlistFetcher.response);
+ } else {
+ console.error("playlist must have one of the playlist MIME type '" + PLAYLIST_MIME_TYPES + "' but it had MIME type '" + playlistFetcher.response.type + "'.");
+ onerror();
+ }
+ };
+ playlistFetcher.onerror = onerror;
+ playlistFetcher.abort = onerror;
+ playlistFetcher.send();
+}
+function servedPartialDataAndCanRequestAll (xhr) {
+ if (xhr.status === 206) {
+ if (xhr.getResponseHeader("content-range").includes("/")) {
+ if (!xhr.getResponseHeader("content-range").includes("/*")) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+function prefetchTrack(url, onload) {
+ if (prefetchedTracks.has(url)) {
+ return;
+ }
+ // first cleanup: kill the oldest entries until we're back at the allowed size
+ while (prefetchedTracks.size > MAX_PREFETCH_KEEP) {
+ const key = prefetchedTracks.keys().next().value;
+ const track = prefetchedTracks.get(key);
+ prefetchedTracks.delete(key);
+ }
+ // first set the prefetched to the url so we will never request twice
+ prefetchedTracks.set(url, url);
+ // now start replacing it with a blob
+ const xhr = new XMLHttpRequest();
+ xhr.open("GET", url, true);
+ xhr.responseType = "blob";
+ xhr.onload = () => {
+ if (servedPartialDataAndCanRequestAll(xhr)) {
+ const endRange = Number(xhr.getResponseHeader("content-range").split("/")[1]) - 1;
+ const rangeXhr = new XMLHttpRequest();
+ rangeXhr.open("GET", url, true);
+ rangeXhr.responseType = "blob";
+ rangeXhr.setRequestHeader("range", "bytes=0-" + endRange);
+ rangeXhr.onload = () => {
+ prefetchedTracks.set(url, rangeXhr.response);
+ if (onload) {
+ onload();
+ }
+ };
+ rangeXhr.send();
+ } else {
+ prefetchedTracks.set(url, xhr.response);
+ if (onload) {
+ onload();
+ }
+ }
+ };
+ xhr.send();
+}
+
+function showStaticOverlay(mediaTag, canvas) {
+ if (mediaTag instanceof Audio) {
+ return;
+ }
+ // take screenshot of video and overlay it to mask short-term flicker.
+ const realWidth = mediaTag.getBoundingClientRect().width;
+ const realHeight = mediaTag.getBoundingClientRect().height;
+ canvas.width = realWidth;
+ canvas.height = realHeight;
+ // need the actual video size
+ const videoAspectRatio = mediaTag.videoHeight / mediaTag.videoWidth;
+ const tagAspectRatio = realHeight / realWidth;
+ const videoIsPartialHeight = tagAspectRatio > (videoAspectRatio * 1.01); // avoid rounding errors
+ const videoIsPartialWidth = videoAspectRatio > (tagAspectRatio * 1.01); // avoid rounding errors
+ if (videoIsPartialHeight) {
+ canvas.height = realWidth * videoAspectRatio;
+ } else if (videoIsPartialWidth) {
+ canvas.width = realHeight / videoAspectRatio;
+ }
+ const context = canvas.getContext("2d");
+ context.scale(canvas.width / mediaTag.videoWidth, canvas.height / mediaTag.videoHeight);
+ context.drawImage(mediaTag, 0, 0);
+ canvas.hidden = true;
+ mediaTag.parentNode.insertBefore(canvas, mediaTag.nextSibling);
+ canvas.style.position = "absolute";
+ // shift canvas to cover only the space where the video actually is
+ if (videoIsPartialWidth) {
+ canvas.style.marginLeft = "-" + ((realWidth + canvas.width) / 2.) + "px";
+ } else {
+ canvas.style.marginLeft = "-" + realWidth + "px";
+ }
+ if (videoIsPartialHeight) {
+ canvas.style.marginTop = ((realHeight - canvas.height) / 2.) + "px";
+ }
+ canvas.hidden = false;
+}
+
+function updateSrc(mediaTag, callback) {
+ const playlistUrl = mediaTag.getAttribute("playlist");
+ const trackIndex = mediaTag.getAttribute("track-index");
+ // deepcopy playlists to avoid shared mutation
+ let playlist = [...playlists[playlistUrl]];
+ let trackUrl = playlist[trackIndex];
+ // download and splice in playlists as needed
+ if (isPlaylist(trackUrl)) {
+ if (playlist.length >= MAX_PLAYLIST_LENGTH) {
+ // skip playlist if we already have too many tracks
+ changeTrack(mediaTag, +1);
+ } else {
+ // do not use the cached playlist here, though it is tempting: it might genuinely change to allow for updates
+ fetchPlaylist(
+ trackUrl,
+ () => {
+ playlist.splice(trackIndex, 1, ...playlists[trackUrl]);
+ playlists[playlistUrl] = playlist;
+ updateSrc(mediaTag, callback);
+ },
+ () => callback());
+ }
+ } else {
+ let url = prefetchedTracks.has(trackUrl)
+ ? prefetchedTracks.get(trackUrl) instanceof Blob
+ ? URL.createObjectURL(prefetchedTracks.get(trackUrl))
+ : trackUrl : trackUrl;
+ const oldUrl = mediaTag.getAttribute("src");
+ // prevent size flickering by setting height before src change
+ const canvas = document.createElement("canvas");
+ if (!isNaN(mediaTag.duration) // already loaded a valid file
+ && document.fullscreen !== true) { // overlay does not work for fullscreen
+ // mask flickering with a static overlay
+ try {
+ showStaticOverlay(mediaTag, canvas);
+ } catch (error) {
+ console.log(error);
+ }
+ }
+ // force sizes to stay constant during loading of the next segment
+ mediaTag.style.height = mediaTag.getBoundingClientRect().height.toString() + 'px';
+ mediaTag.style.width = mediaTag.getBoundingClientRect().width.toString() + 'px';
+ // swich to the next segment
+ mediaTag.setAttribute("src", url);
+ mediaTag.oncanplaythrough = () => {
+ if (!isNaN(mediaTag.duration)) { // already loaded a valid file
+ // unset element styles to allow recomputation if sizes changed
+ mediaTag.style.height = null;
+ mediaTag.style.width = null;
+ }
+ // remove overlay
+ canvas.hidden = true;
+ canvas.remove(); // to allow garbage collection
+ };
+ setTimeout(() => canvas.remove(), 300); // fallback
+ // replace the url when done, because a blob from an xhr request
+ // is more reliable in the media tag;
+ // the normal URL caused jumping prematurely to the next track.
+ if (url == trackUrl) {
+ prefetchTrack(trackUrl, () => {
+ if (mediaTag.paused) {
+ if (url == mediaTag.getAttribute("src")) {
+ if (mediaTag.currentTime === 0) {
+ mediaTag.setAttribute("src", URL.createObjectURL(
+ prefetchedTracks.get(url)));
+ }
+ }
+ }
+ });
+ }
+ // allow releasing memory
+ if (isBlob(oldUrl)) {
+ URL.revokeObjectURL(oldUrl);
+ }
+ // update title
+ mediaTag.parentElement.querySelector(".m3u-player--title").title = trackUrl;
+ mediaTag.parentElement.querySelector(".m3u-player--title").textContent = trackUrl;
+ // start prefetching the next three tracks.
+ for (const i of [1, 2, 3]) {
+ if (playlist.length > Number(trackIndex) + i) {
+ prefetchTrack(playlist[Number(trackIndex) + i]);
+ }
+ }
+ callback();
+ }
+}
+function changeTrack(mediaTag, diff) {
+ const currentTrackIndex = Number(mediaTag.getAttribute("track-index"));
+ const nextTrackIndex = currentTrackIndex + diff;
+ const tracks = playlists[mediaTag.getAttribute("playlist")];
+ if (nextTrackIndex >= 0) { // do not collapse the if clauses with double-and, that does not survive inlining
+ if (tracks.length > nextTrackIndex) {
+ mediaTag.setAttribute("track-index", nextTrackIndex);
+ updateSrc(mediaTag, () => mediaTag.play());
+ }
+ }
+}
+
+/**
+ * Turn a media tag into playlist player.
+ */
+function initPlayer(mediaTag) {
+ mediaTag.setAttribute("playlist", mediaTag.getAttribute("src"));
+ mediaTag.setAttribute("track-index", 0);
+ const url = mediaTag.getAttribute("playlist");
+ const wrapper = mediaTag.parentElement.insertBefore(document.createElement("div"), mediaTag);
+ const controls = document.createElement("div");
+ const left = document.createElement("span");
+ const title = document.createElement("span");
+ const right = document.createElement("span");
+ controls.appendChild(left);
+ controls.appendChild(title);
+ controls.appendChild(right);
+ left.classList.add("m3u-player--left");
+ right.classList.add("m3u-player--right");
+ title.classList.add("m3u-player--title");
+ title.style.overflow = "hidden";
+ title.style.textOverflow = "ellipsis";
+ title.style.whiteSpace = "nowrap";
+ title.style.opacity = "0.3";
+ title.style.direction = "rtl"; // for truncation on the left
+ title.style.paddingLeft = "0.5em";
+ title.style.paddingRight = "0.5em";
+ controls.style.display = "flex";
+ controls.style.justifyContent = "space-between";
+ const styleTag = document.createElement("style");
+ styleTag.innerHTML = ".m3u-player--left:hover, .m3u-player--right:hover {color: wheat; background-color: DarkSlateGray}";
+ wrapper.appendChild(styleTag);
+ wrapper.appendChild(controls);
+ controls.style.width = mediaTag.getBoundingClientRect().width.toString() + "px";
+ // appending the media tag to the wrapper removes it from the outer scope but keeps the event listeners
+ wrapper.appendChild(mediaTag);
+ left.innerHTML = "<"; // not textContent, because we MUST escape
+ // the tag here and textContent shows the
+ // escaped version
+ left.onclick = () => changeTrack(mediaTag, -1);
+ right.innerHTML = ">";
+ right.onclick = () => changeTrack(mediaTag, +1);
+ fetchPlaylist(
+ url,
+ () => {
+ updateSrc(mediaTag, () => null);
+ mediaTag.addEventListener("ended", event => {
+ if (mediaTag.currentTime >= mediaTag.duration) {
+ changeTrack(mediaTag, +1);
+ }
+ });
+ },
+ () => null);
+ // keep the controls aligned to the media tag
+ mediaTag.resizeObserver = new ResizeObserver(entries => {
+ controls.style.width = entries[0].contentRect.width.toString() + "px";
+ });
+ mediaTag.resizeObserver.observe(mediaTag);
+}
+function processTag(mediaTag) {
+ const canPlayClaim = mediaTag.canPlayType('audio/x-mpegurl');
+ let supportsPlaylists = !!canPlayClaim;
+ if (canPlayClaim == 'maybe') { // yes, seriously: specced as you only know when you try
+ supportsPlaylists = false;
+ }
+ if (!supportsPlaylists) {
+ if (isPlaylist(mediaTag.getAttribute("src"))) {
+ initPlayer(mediaTag);
+ }
+ }
+}
+document.addEventListener('DOMContentLoaded', () => {
+ const nodes = document.querySelectorAll("audio,video");
+ nodes.forEach(processTag);
+});
+// @license-end