Files
scripts/js/reddit-image-inline.user.js
T

188 lines
6.5 KiB
JavaScript

// ==UserScript==
// @name Inline Reddit images and Comment Redirect
// @namespace https://www.reddit.com
// @version 2026-08-28.1
// @description Embed images and videos posted as comments and redirect post links to comments
// @author luxick
// @updateURL https://git.luxick.de/luxick/scripts/js/raw/branch/master/reddit-image-inline.user.js
// @downloadURL https://git.luxick.de/luxick/scripts/js/raw/branch/master/reddit-image-inline.user.js
// @match https://old.reddit.com/r/*
// @match https://www.reddit.com/r/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=reddit.com
// @grant none
// ==/UserScript==
(function() {
'use strict';
// Collect the placeholder links Reddit renders for media comments,
// e.g. "<image>" or "<video>" as the link text.
function collectLinks(placeholder) {
let xpath = "//a[text()='" + placeholder + "']";
var element;
var result = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
let links = [];
while(element = result.iterateNext()){
if (element.href) {
links.push(element);
}
}
return links;
}
// Comment media is linked through Reddit's viewer:
// "https://reddit.com/media?url=<media url>"
function unwrapMediaUrl(href) {
let match = href.match(/[?&]url=([^&]+)/);
if (!match) {
return href;
}
try {
return decodeURIComponent(match[1]);
} catch (e) {
return match[1];
}
}
// Video comments point at an HLS playlist, which only Safari plays without
// a library. The same upload is also served as standalone MP4 renditions,
// so use those instead.
//
// Newer uploads name them CMAF_<height>.mp4, older ones DASH_<height>.mp4,
// and no upload has every height. Missing names return 403, so just walk
// the list until one loads. 720 first as a sane default for a 300px embed;
// 1080 last, only worth fetching when nothing smaller exists.
const HEIGHTS = [720, 480, 360, 270, 240, 220, 1080];
const FAMILIES = ['CMAF', 'DASH'];
const AUDIO_NAMES = [
'CMAF_AUDIO_128', 'CMAF_AUDIO_64',
'DASH_AUDIO_128', 'DASH_AUDIO_64', 'DASH_audio',
];
function mediaUrls(id, names) {
return names.map(name => 'https://v.redd.it/' + id + '/' + name + '.mp4');
}
function videoNames() {
let names = [];
for (let family of FAMILIES) {
for (let height of HEIGHTS) {
names.push(family + '_' + height);
}
}
return names;
}
function vredditId(url) {
let match = url.match(/^https?:\/\/v\.redd\.it\/([A-Za-z0-9]+)/i);
return match ? match[1] : null;
}
// Move to the next candidate URL whenever the current one fails to load.
function chainSources(media, sources, onExhausted) {
let index = 0;
media.src = sources[0];
media.addEventListener('error', function() {
index += 1;
if (index < sources.length) {
media.src = sources[index];
media.load();
} else if (onExhausted) {
onExhausted();
}
});
}
// The MP4 renditions carry the video track only; the audio is a separate
// file that has to be kept in step with the video element by hand.
function buildAudio(video, id) {
let audio = document.createElement('audio');
audio.preload = 'metadata';
chainSources(audio, mediaUrls(id, AUDIO_NAMES));
video.addEventListener('play', function() {
audio.currentTime = video.currentTime;
audio.play().catch(() => {});
});
video.addEventListener('pause', () => audio.pause());
video.addEventListener('seeking', () => { audio.currentTime = video.currentTime; });
video.addEventListener('ratechange', () => { audio.playbackRate = video.playbackRate; });
video.addEventListener('volumechange', function() {
audio.volume = video.volume;
audio.muted = video.muted;
});
return audio;
}
function buildVideo(href) {
let container = document.createElement('span');
let mediaUrl = unwrapMediaUrl(href);
let id = vredditId(mediaUrl);
let video = document.createElement('video');
video.controls = true;
video.preload = 'metadata';
video.style = "max-width: 300px";
let sources = id ? mediaUrls(id, videoNames()) : [mediaUrl];
chainSources(video, sources, function() {
console.log("no playable source for " + href);
let anchor = document.createElement('a');
anchor.href = href;
anchor.target = '_blank';
anchor.textContent = '<video>';
container.replaceChildren(anchor);
});
container.appendChild(video);
if (id) {
container.appendChild(buildAudio(video, id));
}
return container;
}
// Handle image inlining
for (var elem of collectLinks('<image>')) {
console.log("inlining image " + elem.href);
let image = new Image();
image.src = elem.href;
image.style = "max-width: 300px";
// Create a new anchor element
let anchor = document.createElement('a');
anchor.href = elem.href;
anchor.target = '_blank'; // Opens in new tab
// Append the image to the anchor
anchor.appendChild(image);
// Add the anchor (containing the image) to the parent
elem.parentElement.appendChild(anchor);
elem.remove();
}
// Handle video inlining
for (var videoElem of collectLinks('<video>')) {
console.log("inlining video " + videoElem.href);
videoElem.parentElement.appendChild(buildVideo(videoElem.href));
videoElem.remove();
}
// Only proceed with link rewriting if we're not already in a comments section
if (!window.location.pathname.includes('/comments/')) {
// Handle post link redirection
const postLinks = document.querySelectorAll('a.title');
postLinks.forEach(link => {
// Find the comments link for this post
const thing = link.closest('.thing');
if (thing) {
const commentsLink = thing.querySelector('a.comments');
if (commentsLink) {
link.href = commentsLink.href;
}
}
});
}
})();