Add javascript to play m3u file via audio tag

This is an open source (GPL licensed) javascript file which
fetches, parses an M3U formatted file and attaches the files
to the corresponding audio tag within a page.
This commit is contained in:
2025-11-15 12:10:28 -05:00
parent ae96d602a4
commit 354d8d24cd

View File

@@ -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