// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 let ffmpeg = null; (function () { if (Hls.isSupported()) { var downloadsEnabled = document.cookie.split("; ").find((row) => row.startsWith("ffmpeg_video_downloads="))?.split("=")[1] == "on"; var videoSources = document.querySelectorAll("video source[type='application/vnd.apple.mpegurl']"); videoSources.forEach(function (source) { var playlist = source.src; var oldVideo = source.parentNode; var autoplay = oldVideo.classList.contains("hls_autoplay"); // If HLS is supported natively then don't use hls.js if (oldVideo.canPlayType(source.type) === "probably") { if (autoplay) { oldVideo.play(); } return; } // Replace video with copy that will have all "source" elements removed var newVideo = oldVideo.cloneNode(true); var allSources = newVideo.querySelectorAll("source"); allSources.forEach(function (source) { source.remove(); }); // Empty source to enable play event newVideo.src = "about:blank"; oldVideo.parentNode.replaceChild(newVideo, oldVideo); function initializeHls() { newVideo.removeEventListener('play', initializeHls); var hls = new Hls({ autoStartLoad: false }); hls.loadSource(playlist); hls.attachMedia(newVideo); hls.on(Hls.Events.MANIFEST_PARSED, function () { hls.loadLevel = hls.levels.length - 1; var availableLevels = hls.levels.map(function(level) { return { height: level.height, width: level.width, bitrate: level.bitrate, }; }); addQualitySelector(newVideo, hls, availableLevels); if (downloadsEnabled){ addVideoDownload(newVideo, hls); } hls.startLoad(); newVideo.play(); }); hls.on(Hls.Events.ERROR, function (event, data) { var errorType = data.type; var errorFatal = data.fatal; if (errorFatal) { switch (errorType) { case Hls.ErrorType.NETWORK_ERROR: hls.startLoad(); break; case Hls.ErrorType.MEDIA_ERROR: hls.recoverMediaError(); break; default: hls.destroy(); break; } } console.error("HLS error", data); }); } if (downloadsEnabled){ const { fetchFile } = FFmpegUtil; const { FFmpeg } = FFmpegWASM; function addVideoDownload(videoElement, hlsInstance) { var mediaStream = []; var downloadButton = document.createElement('button'); downloadButton.classList.add('video-options','download'); downloadButton.innerText = "⏳" const mergeStreams = async () => { if (ffmpeg === null) { ffmpeg = new FFmpeg(); await ffmpeg.load({ coreURL: "/ffmpeg/ffmpeg-core.js", }); ffmpeg.on("log", ({ message }) => { console.log(message); // This is quite noisy but i will include it }) ffmpeg.on("progress", ({ progress, time }) => { // Progress TODO: show progress ring around button not just ⏳ // console.log("ffmpeg prog:",progress * 100) }); } // Combine Video Audio Streams await ffmpeg.writeFile("video", await fetchFile(concatBlob(mediaStream['video']))); await ffmpeg.writeFile("audio", await fetchFile(concatBlob(mediaStream['audio']))); console.time('ffmpeg-exec'); await ffmpeg.exec(['-i', "video", '-i', "audio",'-c:v', "copy", '-c:a', "aac", 'output.mp4']); console.timeEnd('ffmpeg-exec') // Save const toSlug = (str) => { return str .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[\W_]+/g, '-') .toLowerCase() .replace(/^-+|-+$/g, ''); } var filename = toSlug(videoElement.parentNode.parentNode.id || document.title) const data = await ffmpeg.readFile('output.mp4'); saveAs(new Blob([data.buffer]),filename); return } function saveAs(blob, filename) { // Yeah ok... var url = URL.createObjectURL(blob); var a = document.createElement("a"); document.body.appendChild(a); a.style = "display: none"; a.href = url; a.download = filename; a.click(); window.URL.revokeObjectURL(url); } function concatBlob(inputArray) { var totalLength = inputArray.reduce(function (prev, cur) { return prev + cur.length }, 0); var result = new Uint8Array(totalLength); var offset = 0; inputArray.forEach(function (element) { result.set(element, offset); offset += element.length; }); return new Blob([result], { type: 'application/octet-stream' }); } function getStreams() { var video = document.createElement('video'); video.autoplay = true; var dataStreams = { 'video': [], 'audio': [] }; mediaStream = dataStreams; // Update stream hlsInstance.on(Hls.Events.BUFFER_APPENDING, function (event, data) { dataStreams[data.type].push(data.data); }); var isDownloading = false function startDownload() { if (!isDownloading) { isDownloading = true } else { return } downloadButton.innerText = "⏳" mergeStreams() .then(_ => { isDownloading = false downloadButton.innerText = "⭳" }); } function waitForLoad() { const poll = resolve => { if(hlsInstance._media.buffered.length === 1 && hlsInstance._media.buffered.start(0) === 0 && hlsInstance._media.buffered.end(0) === hlsInstance._media.duration) resolve(); else setTimeout(_ => poll(resolve), 400); } return new Promise(poll); } waitForLoad(_ => flag === true) .then(_ => { downloadButton.innerText = "⭳" downloadButton.addEventListener('click', startDownload); }); } videoElement.parentNode.appendChild(downloadButton); getStreams() } } function addQualitySelector(videoElement, hlsInstance, availableLevels) { var qualitySelector = document.createElement('select'); qualitySelector.classList.add('video-options'); var last = availableLevels.length - 1; availableLevels.forEach(function (level, index) { var option = document.createElement('option'); option.value = index.toString(); var bitrate = (level.bitrate / 1_000).toFixed(0); option.text = level.height + 'p (' + bitrate + ' kbps)'; if (index === last) { option.selected = "selected"; } qualitySelector.appendChild(option); }); qualitySelector.selectedIndex = availableLevels.length - 1; qualitySelector.addEventListener('change', function () { var selectedIndex = qualitySelector.selectedIndex; hlsInstance.nextLevel = selectedIndex; hlsInstance.startLoad(); }); videoElement.parentNode.appendChild(qualitySelector); } newVideo.addEventListener('play', initializeHls); if (autoplay) { newVideo.play(); } }); } else { var videos = document.querySelectorAll("video.hls_autoplay"); videos.forEach(function (video) { video.setAttribute("autoplay", ""); }); } })(); // @license-end