Skip to content

Commit

Permalink
Support for all v.redd.it videos
Browse files Browse the repository at this point in the history
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
  • Loading branch information
larsjohnsen committed Jul 22, 2020
1 parent 4392e56 commit 507c0b2
Show file tree
Hide file tree
Showing 5 changed files with 213 additions and 49 deletions.
55 changes: 41 additions & 14 deletions lib/modules/hosts/vreddit.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* @flow */

import { sortBy } from 'lodash-es';
import { difference, sortBy } from 'lodash-es';
import { Host } from '../../core/host';
import { ajax } from '../../environment';

Expand All @@ -10,32 +10,59 @@ export default new Host('vreddit', {
permissions: ['https://v.redd.it/*/DASHPlaylist.mpd'],
attribution: false,
options: {
replaceNativeExpando: {
title: 'showImagesReplaceNativeExpandoTitle',
description: 'showImagesReplaceNativeExpandoDesc',
value: false,
forceReplaceNativeExpando: {
title: 'showImagesForceReplaceNativeExpandoTitle',
description: 'showImagesForceReplaceNativeExpandoDesc',
value: true,
type: 'boolean',
},
minimumVideoBandwidth: {
title: 'showImagesVredditMinimumVideoBandwidthTitle',
description: 'showImagesVredditMinimumVideoBandwidthDesc',
value: '3000', // In kB/s
type: 'text',
advanced: true,
},
},
detect: ({ pathname }) => pathname.slice(1),
async handleLink(href, id) {
const mpd = await ajax({ url: `https://v.redd.it/${id}/DASHPlaylist.mpd` });
const manifest = new DOMParser().parseFromString(mpd, 'text/xml');
// Audio is in a seperate stream, and requires a heavy dash dependency to add to the video
if (manifest.querySelector('AudioChannelConfiguration')) throw new Error('Audio is not supported');
const reps = Array.from(manifest.querySelectorAll('Representation'));
const sources = sortBy(reps, rep => parseInt(rep.getAttribute('bandwidth'), 10))

const minBandwidth = parseInt(this.options.minimumVideoBandwidth.value, 10) * 1000;
const reps = manifest.querySelectorAll('Representation[frameRate]');
const videoSourcesByBandwidth = sortBy(reps, rep => parseInt(rep.getAttribute('bandwidth'), 10))
.reverse()
.map(rep => rep.querySelector('BaseURL'))
.map(baseUrl => ({
source: `https://v.redd.it/${id}/${baseUrl.textContent}`,
.filter((rep, i, arr) => {
const bandwidth = parseInt(rep.getAttribute('bandwidth'), 10);
return rep === arr[0] || bandwidth >= minBandwidth;
});

// Removes unwanted entries from the from manifest
for (const rep of difference(reps, videoSourcesByBandwidth)) rep.remove();

// Update baseURL to absolute URL
for (const rep of manifest.querySelectorAll('Representation')) {
const baseURLElement = rep.querySelector('BaseURL');
baseURLElement.textContent = `https://v.redd.it/${id}/${baseURLElement.textContent}`;
}

// Audio is in a seperate stream, and requires a heavy dash dependency to add to the video
const muted = !manifest.querySelector('AudioChannelConfiguration');

const sources = (muted && id) ?
videoSourcesByBandwidth.map(rep => ({
source: rep.querySelector('BaseURL').textContent,
type: 'video/mp4',
}));
})) : [{
source: URL.createObjectURL(new Blob([(new XMLSerializer()).serializeToString(manifest)], { type: 'application/dash+xml' })),
type: 'application/dash+xml',
}];

return {
type: 'VIDEO',
loop: true,
muted: true,
muted,
sources,
};
},
Expand Down
129 changes: 100 additions & 29 deletions lib/modules/showImages.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
/* @flow */

// $FlowIgnore HLS media requires a fairly big dependency, so load it separately on demand
import 'file-loader?name=dash.mediaplayer.min.js!../../node_modules/dashjs/dist/dash.mediaplayer.min.js'; // eslint-disable-line import/no-extraneous-dependencies
/* global dashjs:readonly */
/*:: import dashjs from 'dashjs' */

import $ from 'jquery';
import { compact, pull, without, once, memoize, intersection } from 'lodash-es';
import { pull, without, once, memoize, intersection } from 'lodash-es';
import DOMPurify from 'dompurify';
import type {
ExpandoMedia,
Expand All @@ -14,10 +19,12 @@ import type {
GenericMedia,
} from '../core/host';
import { Host } from '../core/host';
import { loadOptions } from '../core/init';
import { Module } from '../core/module';
import {
positiveModulo,
downcast,
filterMap,
Thing,
SelectedThing,
addCSS,
Expand All @@ -30,6 +37,7 @@ import {
frameThrottle,
isPageType,
isAppType,
stopPageContextScript,
string,
waitForEvent,
watchForElements,
Expand All @@ -44,9 +52,11 @@ import {
download,
isPrivateBrowsing,
openNewTab,
loadScript,
Permissions,
Storage,
} from '../environment';
import * as Modules from '../core/modules';
import * as Options from '../core/options';
import * as Notifications from './notifications';
import * as SettingsNavigation from './settingsNavigation';
Expand All @@ -66,6 +76,7 @@ import {
expandos,
activeExpandos,
} from './showImages/expando';
import vreddit from './hosts/vreddit';
import __hosts from 'sibling-loader?import=default!./hosts/default';

const siteModules: Map<string, Host<any, any>> = new Map(
Expand Down Expand Up @@ -439,6 +450,25 @@ module.options = {
return options;
}, {}),
};

module.onInit = () => {
if (isAppType('r2')) {
// We'll probably replace Reddit's video player, so disable the script containing it for now
// This happens on `onInit` since RES' options load slower than the script's
const preventVideoPlayerScriptTasks = [
stopPageContextScript(script => (/^\/?videoplayer\./).test(new URL(script.src, location.origin).pathname), 'head'),
// Reddit loads scripts which initializes the video player, which will cause a slowdown if not blocked
stopPageContextScript(script => !!script.innerHTML.match('RedditVideoPlayer'), '#siteTable'),
];

loadOptions.then(() => {
// We might need to restore the native player
const removeNativePlayer = Modules.isRunning(module) && isSiteModuleEnabled(vreddit) && vreddit.options && vreddit.options.forceReplaceNativeExpando.value;
if (!removeNativePlayer) forEachSeq(preventVideoPlayerScriptTasks, ({ undo }) => undo());
});
}
};

module.exclude = [
/^\/ads\/[\-\w\._\?=]*/i,
'submit',
Expand Down Expand Up @@ -833,11 +863,6 @@ async function checkElementForMedia(element: HTMLAnchorElement) {

if (nativeExpando) {
trackNativeExpando(nativeExpando, element, thing);

if (nativeExpando.open) {
console.log('Native expando has already been opened; skipping.', element.href);
return;
}
}

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

for (const siteModule of modulesForHostname(mediaUrl.hostname)) {
if (nativeExpando) {
const { options: { replaceNativeExpando } = {} } = siteModule;
if (replaceNativeExpando && !replaceNativeExpando.value) continue;
}

const detectResult = siteModule.detect(mediaUrl, thing);
if (!detectResult) continue;

if (nativeExpando) {
const forceReplaceNativeExpandoOption = siteModule.options && siteModule.options.forceReplaceNativeExpando;
if (nativeExpando.open && !(forceReplaceNativeExpandoOption && forceReplaceNativeExpandoOption.value)) {
console.log('Native expando has already been opened; skipping.', element.href);
return;
}

nativeExpando.detach();
}

const expando = new Expando(mediaUrl.href);

if (nativeExpando) nativeExpando.detach();
placeExpando(expando, element, thing);
expando.onExpand(() => { trackMediaLoad(element, thing); });
linksMap.set(element, expando);
Expand Down Expand Up @@ -1089,8 +1118,8 @@ export class Media {

supportsUnload(): boolean { return false; }
_state: 'loaded' | 'unloaded' = 'loaded';
_unload(): void {}
_restore(): void {}
_unload(): any {}
_restore(): any {}

setLoaded(state: boolean) {
if (state) {
Expand Down Expand Up @@ -1832,6 +1861,7 @@ class Video extends Media {
time: number;
frameRate: number;
useVideoManager: boolean;
dashPlayer: *;

constructor({
title,
Expand Down Expand Up @@ -1883,14 +1913,32 @@ class Video extends Media {
}
};

const sourceElements = $(compact(sources.map(v => {
if (!this.video.canPlayType(v.type)) return null;
const source = document.createElement('source');
source.src = v.source;
source.type = v.type;
if (v.reverse) source.dataset.reverse = v.reverse;
return source;
}))).appendTo(this.video).get();
const sourceElements = filterMap(sources, v => {
if (this.video.canPlayType(v.type)) {
const source = document.createElement('source');
source.src = v.source;
source.type = v.type;
if (v.reverse) source.dataset.reverse = v.reverse;
return [source];
} else {
if (v.type === 'application/dash+xml') {
// Use external library
this.dashPlayer = loadScript('/dash.mediaplayer.min.js').then(() => {
dashjs.skipAutoCreate = true;

const player = dashjs.MediaPlayer().create(); // eslint-disable-line new-cap

player.initialize();
player.attachSource(v.source);
player.preload();

return player;
});

return [document.createElement('span')]; // Return dummy element as the proper `source` element has side effects
}
}
});

if (!sourceElements.length) {
if (fallback) {
Expand All @@ -1906,12 +1954,18 @@ class Video extends Media {
}
}

this.video.append(...sourceElements);

const lastSource = sourceElements[sourceElements.length - 1];
lastSource.addEventListener('error', displayError);

if (reversed) this.reverse();

this.ready = Promise.race([waitForEvent(this.video, 'suspend'), waitForEvent(lastSource, 'error')]);
this.ready = Promise.race([
waitForEvent(this.video, 'suspend'),
waitForEvent(lastSource, 'error'),
waitForEvent(this.video, 'ended'),
]);

const setPlayIcon = () => {
if (!this.video.paused) this.element.setAttribute('playing', '');
Expand Down Expand Up @@ -1960,7 +2014,7 @@ class Video extends Media {
this.setMaxSize(this.video);
this.makeZoomable(this.video);
this.addControls(this.video, undefined, sources[0].source);
this.addControls(this.video, undefined, sourceElements[0].getAttribute('src'));
this.makeMovable(container);
this.keepVisible(container);
this.makeIndependent(container);
Expand Down Expand Up @@ -2082,25 +2136,42 @@ class Video extends Media {
return this.video.paused;
}
_unload() {
async _unload() {
// Video is auto-paused when detached from DOM
if (!this.isAttached()) return;
if (!this.video.paused) this.video.pause();
this.time = this.video.currentTime;
this.video.setAttribute('src', ''); // this.video.src has precedence over any child source element
this.video.load();
const dashPlayer = await this.dashPlayer;
if (dashPlayer) {
dashPlayer.updateSettings({ streaming: { bufferToKeep: 0, bufferAheadToKeep: 0 } });
} else {
this.video.setAttribute('src', ''); // this.video.src has precedence over any child source element
this.video.load();
}
if (this.useVideoManager) mutedVideoManager().unobserve(this.video);
}
_restore() {
if (this.video.hasAttribute('src')) {
async _restore() {
if (!this.dashPlayer && this.video.hasAttribute('src')) {
this.video.removeAttribute('src');
this.video.load();
}
const dashPlayer = await this.dashPlayer;
if (dashPlayer) {
try {
/* if this throws an error, video is not attached */
dashPlayer.getVideoElement();
} catch (err) {
dashPlayer.attachView(this.video);
}
dashPlayer.resetSettings(); // Assumes that the only settings changes are done so in `_unload`
}
this.video.currentTime = this.time;
if (this.autoplay) {
Expand Down
14 changes: 10 additions & 4 deletions locales/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4366,11 +4366,17 @@
"temporaryDropdownLinksTemporarily": {
"message": "(temporarily?)"
},
"showImagesReplaceNativeExpandoTitle": {
"message": "Replace Native Expando"
"showImagesForceReplaceNativeExpandoTitle": {
"message": "Force Replace Native Expando"
},
"showImagesReplaceNativeExpandoDesc": {
"message": "Should RES also attempt to replace Reddit's expando?"
"showImagesForceReplaceNativeExpandoDesc": {
"message": "Always replace Reddit's player."
},
"showImagesVredditMinimumVideoBandwidthTitle": {
"message": "Minimum Video Bandwidth"
},
"showImagesVredditMinimumVideoBandwidthDesc": {
"message": "The lowest video quality (in kB/s) the adaptive player shall use."
},
"imgurPreferResAlbumsTitle": {
"message": "Prefer RES Albums"
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"firefox 78"
],
"dependencies": {
"dashjs": "3.1.1",
"dayjs": "1.8.29",
"dompurify": "2.0.12",
"fast-levenshtein": "2.0.6",
Expand Down

0 comments on commit 507c0b2

Please sign in to comment.