Skip to content

Commit 507c0b2

Browse files
committed
Support for all v.redd.it videos
Allows for inline v.redd.it links, and to replace the native player for faster and improved user experience. - Uses the dash.js library - Since it is fairly large, it's loaded separate from the foreground bundle and only on demand - When `forceReplaceNativeExpando` is enabled, Reddit's video player script is no longer loaded - Improves load times a little
1 parent 4392e56 commit 507c0b2

File tree

5 files changed

+213
-49
lines changed

5 files changed

+213
-49
lines changed

lib/modules/hosts/vreddit.js

+41-14
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* @flow */
22

3-
import { sortBy } from 'lodash-es';
3+
import { difference, sortBy } from 'lodash-es';
44
import { Host } from '../../core/host';
55
import { ajax } from '../../environment';
66

@@ -10,32 +10,59 @@ export default new Host('vreddit', {
1010
permissions: ['https://v.redd.it/*/DASHPlaylist.mpd'],
1111
attribution: false,
1212
options: {
13-
replaceNativeExpando: {
14-
title: 'showImagesReplaceNativeExpandoTitle',
15-
description: 'showImagesReplaceNativeExpandoDesc',
16-
value: false,
13+
forceReplaceNativeExpando: {
14+
title: 'showImagesForceReplaceNativeExpandoTitle',
15+
description: 'showImagesForceReplaceNativeExpandoDesc',
16+
value: true,
1717
type: 'boolean',
1818
},
19+
minimumVideoBandwidth: {
20+
title: 'showImagesVredditMinimumVideoBandwidthTitle',
21+
description: 'showImagesVredditMinimumVideoBandwidthDesc',
22+
value: '3000', // In kB/s
23+
type: 'text',
24+
advanced: true,
25+
},
1926
},
2027
detect: ({ pathname }) => pathname.slice(1),
2128
async handleLink(href, id) {
2229
const mpd = await ajax({ url: `https://v.redd.it/${id}/DASHPlaylist.mpd` });
2330
const manifest = new DOMParser().parseFromString(mpd, 'text/xml');
24-
// Audio is in a seperate stream, and requires a heavy dash dependency to add to the video
25-
if (manifest.querySelector('AudioChannelConfiguration')) throw new Error('Audio is not supported');
26-
const reps = Array.from(manifest.querySelectorAll('Representation'));
27-
const sources = sortBy(reps, rep => parseInt(rep.getAttribute('bandwidth'), 10))
31+
32+
const minBandwidth = parseInt(this.options.minimumVideoBandwidth.value, 10) * 1000;
33+
const reps = manifest.querySelectorAll('Representation[frameRate]');
34+
const videoSourcesByBandwidth = sortBy(reps, rep => parseInt(rep.getAttribute('bandwidth'), 10))
2835
.reverse()
29-
.map(rep => rep.querySelector('BaseURL'))
30-
.map(baseUrl => ({
31-
source: `https://v.redd.it/${id}/${baseUrl.textContent}`,
36+
.filter((rep, i, arr) => {
37+
const bandwidth = parseInt(rep.getAttribute('bandwidth'), 10);
38+
return rep === arr[0] || bandwidth >= minBandwidth;
39+
});
40+
41+
// Removes unwanted entries from the from manifest
42+
for (const rep of difference(reps, videoSourcesByBandwidth)) rep.remove();
43+
44+
// Update baseURL to absolute URL
45+
for (const rep of manifest.querySelectorAll('Representation')) {
46+
const baseURLElement = rep.querySelector('BaseURL');
47+
baseURLElement.textContent = `https://v.redd.it/${id}/${baseURLElement.textContent}`;
48+
}
49+
50+
// Audio is in a seperate stream, and requires a heavy dash dependency to add to the video
51+
const muted = !manifest.querySelector('AudioChannelConfiguration');
52+
53+
const sources = (muted && id) ?
54+
videoSourcesByBandwidth.map(rep => ({
55+
source: rep.querySelector('BaseURL').textContent,
3256
type: 'video/mp4',
33-
}));
57+
})) : [{
58+
source: URL.createObjectURL(new Blob([(new XMLSerializer()).serializeToString(manifest)], { type: 'application/dash+xml' })),
59+
type: 'application/dash+xml',
60+
}];
3461

3562
return {
3663
type: 'VIDEO',
3764
loop: true,
38-
muted: true,
65+
muted,
3966
sources,
4067
};
4168
},

lib/modules/showImages.js

+100-29
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
/* @flow */
22

3+
// $FlowIgnore HLS media requires a fairly big dependency, so load it separately on demand
4+
import 'file-loader?name=dash.mediaplayer.min.js!../../node_modules/dashjs/dist/dash.mediaplayer.min.js'; // eslint-disable-line import/no-extraneous-dependencies
5+
/* global dashjs:readonly */
6+
/*:: import dashjs from 'dashjs' */
7+
38
import $ from 'jquery';
4-
import { compact, pull, without, once, memoize, intersection } from 'lodash-es';
9+
import { pull, without, once, memoize, intersection } from 'lodash-es';
510
import DOMPurify from 'dompurify';
611
import type {
712
ExpandoMedia,
@@ -14,10 +19,12 @@ import type {
1419
GenericMedia,
1520
} from '../core/host';
1621
import { Host } from '../core/host';
22+
import { loadOptions } from '../core/init';
1723
import { Module } from '../core/module';
1824
import {
1925
positiveModulo,
2026
downcast,
27+
filterMap,
2128
Thing,
2229
SelectedThing,
2330
addCSS,
@@ -30,6 +37,7 @@ import {
3037
frameThrottle,
3138
isPageType,
3239
isAppType,
40+
stopPageContextScript,
3341
string,
3442
waitForEvent,
3543
watchForElements,
@@ -44,9 +52,11 @@ import {
4452
download,
4553
isPrivateBrowsing,
4654
openNewTab,
55+
loadScript,
4756
Permissions,
4857
Storage,
4958
} from '../environment';
59+
import * as Modules from '../core/modules';
5060
import * as Options from '../core/options';
5161
import * as Notifications from './notifications';
5262
import * as SettingsNavigation from './settingsNavigation';
@@ -66,6 +76,7 @@ import {
6676
expandos,
6777
activeExpandos,
6878
} from './showImages/expando';
79+
import vreddit from './hosts/vreddit';
6980
import __hosts from 'sibling-loader?import=default!./hosts/default';
7081

7182
const siteModules: Map<string, Host<any, any>> = new Map(
@@ -439,6 +450,25 @@ module.options = {
439450
return options;
440451
}, {}),
441452
};
453+
454+
module.onInit = () => {
455+
if (isAppType('r2')) {
456+
// We'll probably replace Reddit's video player, so disable the script containing it for now
457+
// This happens on `onInit` since RES' options load slower than the script's
458+
const preventVideoPlayerScriptTasks = [
459+
stopPageContextScript(script => (/^\/?videoplayer\./).test(new URL(script.src, location.origin).pathname), 'head'),
460+
// Reddit loads scripts which initializes the video player, which will cause a slowdown if not blocked
461+
stopPageContextScript(script => !!script.innerHTML.match('RedditVideoPlayer'), '#siteTable'),
462+
];
463+
464+
loadOptions.then(() => {
465+
// We might need to restore the native player
466+
const removeNativePlayer = Modules.isRunning(module) && isSiteModuleEnabled(vreddit) && vreddit.options && vreddit.options.forceReplaceNativeExpando.value;
467+
if (!removeNativePlayer) forEachSeq(preventVideoPlayerScriptTasks, ({ undo }) => undo());
468+
});
469+
}
470+
};
471+
442472
module.exclude = [
443473
/^\/ads\/[\-\w\._\?=]*/i,
444474
'submit',
@@ -833,11 +863,6 @@ async function checkElementForMedia(element: HTMLAnchorElement) {
833863

834864
if (nativeExpando) {
835865
trackNativeExpando(nativeExpando, element, thing);
836-
837-
if (nativeExpando.open) {
838-
console.log('Native expando has already been opened; skipping.', element.href);
839-
return;
840-
}
841866
}
842867

843868
if (thing && thing.isCrosspost() && module.options.crossposts.value === 'none') {
@@ -852,17 +877,21 @@ async function checkElementForMedia(element: HTMLAnchorElement) {
852877
}
853878

854879
for (const siteModule of modulesForHostname(mediaUrl.hostname)) {
855-
if (nativeExpando) {
856-
const { options: { replaceNativeExpando } = {} } = siteModule;
857-
if (replaceNativeExpando && !replaceNativeExpando.value) continue;
858-
}
859-
860880
const detectResult = siteModule.detect(mediaUrl, thing);
861881
if (!detectResult) continue;
862882

883+
if (nativeExpando) {
884+
const forceReplaceNativeExpandoOption = siteModule.options && siteModule.options.forceReplaceNativeExpando;
885+
if (nativeExpando.open && !(forceReplaceNativeExpandoOption && forceReplaceNativeExpandoOption.value)) {
886+
console.log('Native expando has already been opened; skipping.', element.href);
887+
return;
888+
}
889+
890+
nativeExpando.detach();
891+
}
892+
863893
const expando = new Expando(mediaUrl.href);
864894

865-
if (nativeExpando) nativeExpando.detach();
866895
placeExpando(expando, element, thing);
867896
expando.onExpand(() => { trackMediaLoad(element, thing); });
868897
linksMap.set(element, expando);
@@ -1089,8 +1118,8 @@ export class Media {
10891118
10901119
supportsUnload(): boolean { return false; }
10911120
_state: 'loaded' | 'unloaded' = 'loaded';
1092-
_unload(): void {}
1093-
_restore(): void {}
1121+
_unload(): any {}
1122+
_restore(): any {}
10941123
10951124
setLoaded(state: boolean) {
10961125
if (state) {
@@ -1832,6 +1861,7 @@ class Video extends Media {
18321861
time: number;
18331862
frameRate: number;
18341863
useVideoManager: boolean;
1864+
dashPlayer: *;
18351865

18361866
constructor({
18371867
title,
@@ -1883,14 +1913,32 @@ class Video extends Media {
18831913
}
18841914
};
18851915

1886-
const sourceElements = $(compact(sources.map(v => {
1887-
if (!this.video.canPlayType(v.type)) return null;
1888-
const source = document.createElement('source');
1889-
source.src = v.source;
1890-
source.type = v.type;
1891-
if (v.reverse) source.dataset.reverse = v.reverse;
1892-
return source;
1893-
}))).appendTo(this.video).get();
1916+
const sourceElements = filterMap(sources, v => {
1917+
if (this.video.canPlayType(v.type)) {
1918+
const source = document.createElement('source');
1919+
source.src = v.source;
1920+
source.type = v.type;
1921+
if (v.reverse) source.dataset.reverse = v.reverse;
1922+
return [source];
1923+
} else {
1924+
if (v.type === 'application/dash+xml') {
1925+
// Use external library
1926+
this.dashPlayer = loadScript('/dash.mediaplayer.min.js').then(() => {
1927+
dashjs.skipAutoCreate = true;
1928+
1929+
const player = dashjs.MediaPlayer().create(); // eslint-disable-line new-cap
1930+
1931+
player.initialize();
1932+
player.attachSource(v.source);
1933+
player.preload();
1934+
1935+
return player;
1936+
});
1937+
1938+
return [document.createElement('span')]; // Return dummy element as the proper `source` element has side effects
1939+
}
1940+
}
1941+
});
18941942

18951943
if (!sourceElements.length) {
18961944
if (fallback) {
@@ -1906,12 +1954,18 @@ class Video extends Media {
19061954
}
19071955
}
19081956

1957+
this.video.append(...sourceElements);
1958+
19091959
const lastSource = sourceElements[sourceElements.length - 1];
19101960
lastSource.addEventListener('error', displayError);
19111961

19121962
if (reversed) this.reverse();
19131963

1914-
this.ready = Promise.race([waitForEvent(this.video, 'suspend'), waitForEvent(lastSource, 'error')]);
1964+
this.ready = Promise.race([
1965+
waitForEvent(this.video, 'suspend'),
1966+
waitForEvent(lastSource, 'error'),
1967+
waitForEvent(this.video, 'ended'),
1968+
]);
19151969

19161970
const setPlayIcon = () => {
19171971
if (!this.video.paused) this.element.setAttribute('playing', '');
@@ -1960,7 +2014,7 @@ class Video extends Media {
19602014

19612015
this.setMaxSize(this.video);
19622016
this.makeZoomable(this.video);
1963-
this.addControls(this.video, undefined, sources[0].source);
2017+
this.addControls(this.video, undefined, sourceElements[0].getAttribute('src'));
19642018
this.makeMovable(container);
19652019
this.keepVisible(container);
19662020
this.makeIndependent(container);
@@ -2082,25 +2136,42 @@ class Video extends Media {
20822136
return this.video.paused;
20832137
}
20842138
2085-
_unload() {
2139+
async _unload() {
20862140
// Video is auto-paused when detached from DOM
20872141
if (!this.isAttached()) return;
20882142
20892143
if (!this.video.paused) this.video.pause();
20902144
20912145
this.time = this.video.currentTime;
2092-
this.video.setAttribute('src', ''); // this.video.src has precedence over any child source element
2093-
this.video.load();
2146+
2147+
const dashPlayer = await this.dashPlayer;
2148+
if (dashPlayer) {
2149+
dashPlayer.updateSettings({ streaming: { bufferToKeep: 0, bufferAheadToKeep: 0 } });
2150+
} else {
2151+
this.video.setAttribute('src', ''); // this.video.src has precedence over any child source element
2152+
this.video.load();
2153+
}
20942154
20952155
if (this.useVideoManager) mutedVideoManager().unobserve(this.video);
20962156
}
20972157
2098-
_restore() {
2099-
if (this.video.hasAttribute('src')) {
2158+
async _restore() {
2159+
if (!this.dashPlayer && this.video.hasAttribute('src')) {
21002160
this.video.removeAttribute('src');
21012161
this.video.load();
21022162
}
21032163
2164+
const dashPlayer = await this.dashPlayer;
2165+
if (dashPlayer) {
2166+
try {
2167+
/* if this throws an error, video is not attached */
2168+
dashPlayer.getVideoElement();
2169+
} catch (err) {
2170+
dashPlayer.attachView(this.video);
2171+
}
2172+
dashPlayer.resetSettings(); // Assumes that the only settings changes are done so in `_unload`
2173+
}
2174+
21042175
this.video.currentTime = this.time;
21052176
21062177
if (this.autoplay) {

locales/locales/en.json

+10-4
Original file line numberDiff line numberDiff line change
@@ -4366,11 +4366,17 @@
43664366
"temporaryDropdownLinksTemporarily": {
43674367
"message": "(temporarily?)"
43684368
},
4369-
"showImagesReplaceNativeExpandoTitle": {
4370-
"message": "Replace Native Expando"
4369+
"showImagesForceReplaceNativeExpandoTitle": {
4370+
"message": "Force Replace Native Expando"
43714371
},
4372-
"showImagesReplaceNativeExpandoDesc": {
4373-
"message": "Should RES also attempt to replace Reddit's expando?"
4372+
"showImagesForceReplaceNativeExpandoDesc": {
4373+
"message": "Always replace Reddit's player."
4374+
},
4375+
"showImagesVredditMinimumVideoBandwidthTitle": {
4376+
"message": "Minimum Video Bandwidth"
4377+
},
4378+
"showImagesVredditMinimumVideoBandwidthDesc": {
4379+
"message": "The lowest video quality (in kB/s) the adaptive player shall use."
43744380
},
43754381
"imgurPreferResAlbumsTitle": {
43764382
"message": "Prefer RES Albums"

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"firefox 78"
5656
],
5757
"dependencies": {
58+
"dashjs": "3.1.1",
5859
"dayjs": "1.8.29",
5960
"dompurify": "2.0.12",
6061
"fast-levenshtein": "2.0.6",

0 commit comments

Comments
 (0)