// [[file:m3u-player.org::*The script][The script:1]] // @license magnet:?xt=urn:btih:cf05388f2679ee054f2beb29a391d25f4e673ac3&dn=gpl-2.0.txt GPL-v2-or-Later 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("button"); const title = document.createElement("span"); const right = document.createElement("button"); 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 = "1"; // 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: #ccc; background-color: #333}"; 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 // The script:1 ends here