diff --git a/src/main.rs b/src/main.rs index 0ec2279..31c5c4a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -235,8 +235,8 @@ async fn main() { app.at("/touch-icon-iphone.png").get(|_| iphone_logo().boxed()); app.at("/apple-touch-icon.png").get(|_| iphone_logo().boxed()); app - .at("/playHLSVideo.js") - .get(|_| resource(include_str!("../static/playHLSVideo.js"), "text/javascript", false).boxed()); + .at("/videoUtils.js") + .get(|_| resource(include_str!("../static/videoUtils.js"), "text/javascript", false).boxed()); app .at("/hls.min.js") .get(|_| resource(include_str!("../static/hls.min.js"), "text/javascript", false).boxed()); diff --git a/src/settings.rs b/src/settings.rs index 4d6d72c..a4a3111 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -82,7 +82,7 @@ pub async fn set(req: Request) -> Result, String> { Some(value) => response.insert_cookie( Cookie::build((name.to_owned(), value.clone())) .path("/") - .http_only(true) + .http_only(name != "ffmpeg_video_downloads") .expires(OffsetDateTime::now_utc() + Duration::weeks(52)) .into(), ), diff --git a/static/playHLSVideo.js b/static/playHLSVideo.js deleted file mode 100644 index 4f54e09..0000000 --- a/static/playHLSVideo.js +++ /dev/null @@ -1,110 +0,0 @@ -// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 -(function () { - if (Hls.isSupported()) { - 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); - - 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); - }); - } - - function addQualitySelector(videoElement, hlsInstance, availableLevels) { - var qualitySelector = document.createElement('select'); - qualitySelector.classList.add('quality-selector'); - 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 diff --git a/static/style.css b/static/style.css index 87b3c3b..79c122a 100644 --- a/static/style.css +++ b/static/style.css @@ -1836,18 +1836,41 @@ td, th { } } -.quality-selector { +.video-options { border: 2px var(--outside) solid; margin-top: 8px; float: right; + border-radius: 5px; + height: 35px; + height: 35px; + margin: 2px; + box-sizing: border-box; } -.quality-selector option { +.video-options option { background-color: var(--background); color: var(--text); } -.quality-selector option:hover { +.video-options option:hover { background-color: var(--accent); color: var(--text); } + +.download { + padding-left: 8px; + padding-right: 8px; + font-size: 20px; + font-weight: 900; + color: var(--accent); + background-color: var(--outside); +} + +.download:hover { + background-color: var(--foreground); + /*color: var(--);*/ +} + +.download:active { + background-color: var(--background); +} \ No newline at end of file diff --git a/static/videoUtils.js b/static/videoUtils.js new file mode 100644 index 0000000..d697e4a --- /dev/null +++ b/static/videoUtils.js @@ -0,0 +1,228 @@ +// @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 diff --git a/templates/search.html b/templates/search.html index 3d91c76..04032a9 100644 --- a/templates/search.html +++ b/templates/search.html @@ -97,9 +97,13 @@ {% endif %} {% endfor %} {% endif %} - {% if prefs.use_hls == "on" %} + {% if prefs.ffmpeg_video_downloads == "on" %} + + + {% endif %} + {% if prefs.use_hls == "on" || prefs.ffmpeg_video_downloads == "on" %} - + {% endif %} {% if params.typed != "sr_user" %} diff --git a/templates/settings.html b/templates/settings.html index 69cdd3e..45f355b 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -84,12 +84,15 @@
+ {% if prefs.ffmpeg_video_downloads != "on" %}
Why?
Reddit videos require JavaScript (via HLS.js) to be enabled to be played with audio. Therefore, this toggle lets you either use Redlib JS-free or utilize this feature.
+ {% endif %} + {% if prefs.ffmpeg_video_downloads == "on" %}ⓘ HLS is required for downloads{% endif %} - +
diff --git a/templates/subreddit.html b/templates/subreddit.html index d4f98b9..f615c4b 100644 --- a/templates/subreddit.html +++ b/templates/subreddit.html @@ -64,9 +64,13 @@ {% call utils::post_in_list(post) %} {% endif %} {% endfor %} - {% if prefs.use_hls == "on" %} + {% if prefs.ffmpeg_video_downloads == "on" %} + + + {% endif %} + {% if prefs.use_hls == "on" || prefs.ffmpeg_video_downloads == "on" %} - + {% endif %}
{% endif %} diff --git a/templates/user.html b/templates/user.html index 42019e7..502ded4 100644 --- a/templates/user.html +++ b/templates/user.html @@ -71,9 +71,13 @@ {% endif %} {% endfor %} - {% if prefs.use_hls == "on" %} + {% if prefs.ffmpeg_video_downloads == "on" %} + + + {% endif %} + {% if prefs.use_hls == "on" || prefs.ffmpeg_video_downloads == "on" %} - + {% endif %} {% endif %} diff --git a/templates/utils.html b/templates/utils.html index a99e2be..d3b0541 100644 --- a/templates/utils.html +++ b/templates/utils.html @@ -117,7 +117,11 @@ {% else if post.post_type == "video" || post.post_type == "gif" %} - {% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() %} + {% if prefs.ffmpeg_video_downloads == "on" %} + + + {% endif %} + {% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() || prefs.ffmpeg_video_downloads == "on" && !post.media.alt_url.is_empty() %}
- + {% else %}
@@ -250,7 +254,7 @@
{% else if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "video" %} - {% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() %} + {% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() || prefs.ffmpeg_video_downloads == "on" && !post.media.alt_url.is_empty() %}