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:
315
public_html/scripts/m3u-player.js
Normal file
315
public_html/scripts/m3u-player.js
Normal 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
|
||||
Reference in New Issue
Block a user