diff --git a/app/src/main/assets/web_extensions/webcompat_youtube/background.js b/app/src/main/assets/web_extensions/webcompat_youtube/background.js index 99ceae544..97d7c80e9 100644 --- a/app/src/main/assets/web_extensions/webcompat_youtube/background.js +++ b/app/src/main/assets/web_extensions/webcompat_youtube/background.js @@ -1,6 +1,7 @@ +const CUSTOM_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12) AppleWebKit/602.1.21 (KHTML, like Gecko) Version/9.2 Safari/602.1.21'; const targetUrls = [ - "https://*.youtube.com/*", - "https://*.youtube-nocookie.com/*" + "https://*.youtube.com/*", + "https://*.youtube-nocookie.com/*" ]; /** @@ -10,24 +11,44 @@ const targetUrls = [ * video pages intended for mobile phones, as linked from Google search results). */ function redirectUrl(req) { - let redirect = false; - const url = new URL(req.url); - if (url.host.startsWith("m.")) { - url.host = url.host.replace("m.", "www."); - redirect = true; - } - if (!url.searchParams.get("disable_polymer")) { - url.searchParams.set("disable_polymer", "1"); - redirect = true; - } - if (!redirect) { - return null; - } - return { redirectUrl: url.toString() }; + let redirect = false; + const url = new URL(req.url); + if (url.host.startsWith("m.")) { + url.host = url.host.replace("m.", "www."); + redirect = true; + } + if (!url.searchParams.get("disable_polymer")) { + url.searchParams.set("disable_polymer", "1"); + redirect = true; + } + if (!redirect) { + return null; + } + return { redirectUrl: url.toString() }; +} + +/** + * Override UA. This is required to get the equirectangular video formats from Youtube. + * Otherwise youtube uses custom 360 controls. + */ +function overrideUA(req) { + for (const header of req.requestHeaders) { + if (header.name.toLowerCase() === "user-agent") { + header.value = CUSTOM_USER_AGENT; + } + } + return { requestHeaders: req.requestHeaders }; } browser.webRequest.onBeforeRequest.addListener( - redirectUrl, - { urls: targetUrls, types: ["main_frame"]}, - ["blocking"] + redirectUrl, + { urls: targetUrls, types: ["main_frame"]}, + ["blocking"] ); + +browser.webRequest.onBeforeSendHeaders.addListener( + overrideUA, + { urls: targetUrls }, + ["blocking", "requestHeaders"] + ); + \ No newline at end of file diff --git a/app/src/main/assets/web_extensions/webcompat_youtube/main.css b/app/src/main/assets/web_extensions/webcompat_youtube/main.css index 03b410669..79ea420b6 100644 --- a/app/src/main/assets/web_extensions/webcompat_youtube/main.css +++ b/app/src/main/assets/web_extensions/webcompat_youtube/main.css @@ -4,3 +4,8 @@ .ytp-generic-popup, ytp-generic-popup { display: none; } + +/* Improve readability a bit in FxR. */ +hr, html, i, iframe, img, ins, kbd, label, legend, li, menu, object, ol, p, pre, q, s, samp, small, span, strike, strong, sub { + font-size: 110%; +} \ No newline at end of file diff --git a/app/src/main/assets/web_extensions/webcompat_youtube/main.js b/app/src/main/assets/web_extensions/webcompat_youtube/main.js index 6d73fc5e3..af919237e 100644 --- a/app/src/main/assets/web_extensions/webcompat_youtube/main.js +++ b/app/src/main/assets/web_extensions/webcompat_youtube/main.js @@ -1,302 +1,219 @@ +'use strict'; const CUSTOM_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12) AppleWebKit/602.1.21 (KHTML, like Gecko) Version/9.2 Safari/602.1.21'; const LOGTAG = '[firefoxreality:webcompat:youtube]'; +const VIDEO_PROJECTION_PARAM = 'mozVideoProjection'; const YT_SELECTORS = { disclaimer: '.yt-alert-message, yt-alert-message', - moviePlayer: '#movie_player' + player: '#movie_player', + embedPlayer: '.html5-video-player', + largePlayButton: '.ytp-large-play-button', + thumbnail: '.ytp-cued-thumbnail-overlay-image', + embedTitle: '.ytp-title-text' }; -const YT_PATHS = { - watch: '/watch' -}; - -try { - // Note: À la Oculus Browser, we intentionally use this particular `User-Agent` string - // for YouTube to force the most optimal, high-resolution layout available for playback in a mobile VR browser. - Object.defineProperty(navigator, 'userAgent', { - get: () => CUSTOM_USER_AGENT - }); - - // If missing, inject a `` tag to trigger YouTube's mobile layout. - let viewportEl = document.querySelector('meta[name="viewport"]'); - if (!viewportEl) { - document.documentElement.insertAdjacentHTML('afterbegin', - ``); - } - - let is360 = null; - let qs = new URLSearchParams(window.location.search); - let retryTimeout = null; - - const prefs = { - hd: false, - quality: 1440, - log: qs.get('mozDebug') !== '0' && qs.get('mozdebug') !== '0' && qs.get('debug') !== '0', - retryAttempts: parseInt(qs.get('retryAttempts') || qs.get('retryattempts') || '10', 10), - retryTimeout: parseInt(qs.get('retryTimeout') || qs.get('retrytimeout') || '500', 10) - }; - - const printLog = String(prefs.log) === 'true'; - - const log = (...args) => printLog && console.log(LOGTAG, ...args); - const logError = (...args) => printLog && console.error(LOGTAG, ...args); - const logWarn = (...args) => printLog && console.warn(LOGTAG, ...args); - - let auto360 = true; - - const onNavigate = (delayTime = 500) => setTimeout(() => { - ytImprover360(auto360); - - ytImprover.completed = false; - ytImprover(1); - }, delayTime); - - window.addEventListener('fullscreenchange', () => { - auto360 = !!document.fullscreenElement; - ytImprover360(auto360); - }); - - window.addEventListener('load', () => { - viewportEl = document.querySelector('meta[name="viewport"]:not([data-fxr-injected])'); - if (viewportEl) { - viewportEl.parentNode.removeChild(viewportEl); - } - - // Wait until video has loaded the first frame to force quality change. - // This prevents the infinite spinner problem. - // See https://github.com/MozillaReality/FirefoxReality/issues/1433 - var video = document.getElementsByTagName("video")[0]; - if (video.readyState >= 2) { - onNavigate(0); - } else { - video.addEventListener("loadeddata", () => onNavigate(0)); - } - }); - - window.addEventListener('pushstate', onNavigate); - - window.addEventListener('popstate', onNavigate); - - window.addEventListener('click', evt => { - if (!window.location.pathname.startsWith(YT_PATHS.watch)) { - return; - } - if (is360 && evt.target.closest(YT_SELECTORS.moviePlayer) && !evt.target.closest('.ytp-chrome-bottom')) { - const playerEl = document.querySelector(YT_SELECTORS.moviePlayer); - if (!playerEl) { - return; - } - playerEl.requestFullscreen(); +const ENABLE_LOGS = true; +const logDebug = (...args) => ENABLE_LOGS && console.log(LOGTAG, ...args); +const logError = (...args) => ENABLE_LOGS && console.error(LOGTAG, ...args); + +class YoutubeExtension { + // We set a custom UA to force Youtube to display the most optimal + // and high-resolution layout available for playback in a mobile VR browser. + overrideUA() { + Object.defineProperty(navigator, 'userAgent', { + get: () => CUSTOM_USER_AGENT + }); + logDebug(`Youtube UA overriden to: ${navigator.userAgent}`) + } + + // If missing, inject a `` tag to trigger YouTube's mobile layout. + overrideViewport() { + const content = `width=device-width;`; + let viewport = document.querySelector('meta[name="viewport"]'); + if (viewport) { + viewport.setAttribute('content', content); + } else { + document.head.insertAdjacentHTML('afterbegin', ``); + } + logDebug(`Youtube viewport updated`); } - }); - function ytImprover360 (auto) { - if (!window.location.pathname.startsWith(YT_PATHS.watch)) { - is360 = false; - return; + // Select a better youtube video quality + overrideQuality() { + logDebug('overrideQuality attempt'); + const player = this.getPlayer(); + if (!player) { + logDebug('player not ready'); + return false; + } + const preferredLevels = this.getPreferredQualities(); + const currentLevel = player.getPlaybackQuality(); + logDebug(`Video getPlaybackQuality: ${currentLevel}`); + + let availableLevels = player.getAvailableQualityLevels(); + logDebug(`Video getAvailableQualityLevels: ${availableLevels}`); + for (const level of preferredLevels) { + if (availableLevels.indexOf(level) >= 0) { + if (currentLevel !== level) { + player.setPlaybackQualityRange(level, level); + logDebug(`Video setPlaybackQualityRange: ${level}`); + } else { + logDebug('Best quality already selected'); + } + return true; + } + } + return false; } - const disclaimerEl = document.querySelector(YT_SELECTORS.disclaimer); - is360 = disclaimerEl ? disclaimerEl.textContent.includes('360') : false; - - if (!is360) { - return; + overrideQualityRetry() { + this.retry("overrideQuality", () => this.overrideQuality()); } - qs = new URLSearchParams(window.location.search); - - const currentProjection = (qs.get('mozVideoProjection') || '').toLowerCase(); - qs.delete('mozVideoProjection'); - switch (currentProjection) { - case '360': - case '360_auto': - case '360s': - case '360s_auto': - case '180': - case '180_auto': - case '180lr': - case '180lr_auto': - case '180tb': - case '180tb_auto': - qs.set('mozVideoProjection', currentProjection); - break; - default: - qs.set('mozVideoProjection', auto ? '360_auto' : '360'); - break; + // Automatically select a video projection if needed + overrideVideoProjection() { + if (!this.isWatchingPage()) { + logDebug("is not watching page"); + return; // Only override projection in the Youtube watching page. + } + const qs = new URLSearchParams(window.location.search); + if (qs.get(VIDEO_PROJECTION_PARAM)) { + logDebug(`Video has already a video projection selected: ${qs.get(VIDEO_PROJECTION_PARAM)}`); + return; + } + // There is no standard API to detect video projection yet. + // Try to infer it from the video disclaimer or title for now. + const targets = [ + document.querySelector(YT_SELECTORS.disclaimer), + document.querySelector(YT_SELECTORS.embedTitle) + ]; + let is360 = targets.some((node) => node && node.textContent.includes('360')); + if (is360) { + qs.set('mozVideoProjection', '360_auto'); + this.updateURL(qs); + logDebug(`Video projection set to: ${qs.get(VIDEO_PROJECTION_PARAM)}`); + } else { + logDebug(`Video is flat, no projection selected`); + } } - const newUrl = getNewUrl(qs); - if (newUrl && (window.location.pathname + window.location.search) !== newUrl) { - window.history.replaceState({}, document.title, newUrl); - return newUrl; + overrideClick(event) { + const player = this.getPlayer(); + if (!this.isWatchingPage() || !this.hasVideoProjection() || document.fullscreenElement || !player) { + return; // Only override click in the Youtube watching page for 360 videos. + } + if (this.isEmbed() && !this.isVideoReady()) { + return false; // Embeds videos are only loaded after the first click + } + const target = event.target; + let valid = target.tagName.toLowerCase() === 'video' || + target === document.querySelector(YT_SELECTORS.thumbnail) || + target === document.querySelector(YT_SELECTORS.largePlayButton) || + target == player; + + if (valid) { + player.playVideo(); + player.requestFullscreen(); + // Force video play when entering immersive mode. + setTimeout(() => this.retry("PlayVideo", () => { + player.playVideo(); + return !document.getElementsByTagName("video")[0].paused; + }), 200); + } } - } - function getNewUrl (qs) { - let newUrl = `${window.location.pathname}`; - if (qs) { - newUrl = `${newUrl}?${qs}`; + // Runs the callback when the video is ready (has loaded the first frame). + waitForVideoReady(callback) { + this.retry("VideoReady", () => { + const video = document.getElementsByTagName("video")[0]; + if (!video) { + return false; + } + if (video.readyState >= 2) { + callback(); + } else { + video.addEventListener("loadeddata", callback, {once: true}); + } + return true; + }); + } + + // Get's the Youtube player elements which contains the API functions. + getPlayer() { + let player = document.querySelector(this.isEmbed() ? YT_SELECTORS.embedPlayer : YT_SELECTORS.player); + if (!player || !player.wrappedJSObject) { + return null; + } + return player.wrappedJSObject; } - return newUrl; - } - const ytImprover = window.ytImprover = (state, attempts) => { - if (!window.location.pathname.startsWith(YT_PATHS.watch)) { - ytImprover.completed = true; - return; - } - if (ytImprover.completed) { - return; + // Get's the preferred video qualities for the current device. + getPreferredQualities() { + let all = ['hd2880', 'hd2160','hd1440', 'hd1080', 'hd720', 'large', 'medium']; + return all; } - if (typeof attempts === 'undefined') { - attempts = 1; - } - if (attempts >= prefs.retryAttempts) { - logError(`Giving up trying to increase resolution after ${prefs.retryAttempts} attempts.`); - return; + // Returns true if we are in a video watching page. + isWatchingPage() { + return window.location.pathname.startsWith('/watch') || this.isEmbed(); } - let player = document.querySelector(YT_SELECTORS.moviePlayer); - let reason = 'unknown'; - if (state !== 1) { - reason = 'invalid state'; - } else if (!player) { - reason = 'player not found'; - } else if (!player.wrappedJSObject) { - reason = 'player.wrappedJSObject not found'; - player = null; - } else if (!player.wrappedJSObject.getAvailableQualityLevels) { - reason = 'player.wrappedJSObject.getAvailableQualityLevels not found'; - player = null; + isEmbed() { + return window.location.pathname.startsWith('/embed'); } - if (!player) { - logWarn(`Cannot find player because ${reason}. attempts: ${attempts}`); - attempts++; - retryTimeout = setTimeout(() => { - ytImprover(state, attempts); - }, prefs.retryTimeout); - return; + // Returns true if we are in a video watching page. + hasVideoProjection() { + const qs = new URLSearchParams(window.location.search); + return !!qs.get(VIDEO_PROJECTION_PARAM); } - player = player.wrappedJSObject; - - const levels = player.getAvailableQualityLevels(); - if (!levels || !levels.length) { - logWarn(`Cannot read 'player.getAvailableQualityLevels()' attempts: ${attempts}`); - attempts++; - retryTimeout = setTimeout(() => { - ytImprover(state, attempts); - }, prefs.retryTimeout); - return; + isVideoReady() { + const video = document.getElementsByTagName("video")[0]; + return video && video.readyState >=2; } - clearTimeout(retryTimeout); - ytImprover.completed = true; - - prefs.qualities = [ - 'highres', 'h2880', 'hd2160', 'hd1440', 'hd1080', 'hd720', 'large', 'medium', 'small', 'tiny', 'auto' - ]; - prefs.qualityLabels = { - '4320': 'highres', // 8K / 4320p / QUHD - '2880': 'hd2880', // 5K / 2880p / UHD+ - '2160': 'hd2160', // 4K / 2160p / UHD - '1440': 'hd1440', // 1440p / QHD - '1080': 'hd1080', // 1080p / FHD - '720': 'hd720', // 720p / HD - '480': 'large', // 480p - '360': 'medium', // 360p - '240': 'small', // 240p - '144': 'tiny', // 144p - '0': 'auto' - }; - - const getDesiredQuality = () => { - const qsQuality = (qs.get('vq') || qs.get('quality') || '').trim().toLowerCase(); - if (qsQuality) { - if (qsQuality in prefs.qualityLabels) { - prefs.quality = prefs.qualityLabels[qsQuality]; - } else { - const qsQualityNumber = parseInt(qsQuality, 10); - if (Number.isInteger(qsQualityNumber)) { - prefs.quality = qsQualityNumber; - } else { - prefs.quality = qsQuality; - } + // Utility function to retry tasks max n times until the execution is successful. + retry(taskName, task, attempts = 10, interval = 200) { + let succeeded = false; + try { + succeeded = task(); + } catch (ex) { + logError(`Got exception runnning ${taskName} task: ${ex}`); } - } - prefs.quality = String(prefs.quality).toLowerCase(); - if (qsQuality === 'auto' || qsQuality === 'default') { - prefs.quality = 'auto'; - } - if (prefs.quality in prefs.qualityLabels) { - prefs.quality = prefs.qualityLabels[prefs.quality]; - } - return prefs.quality; - }; - - prefs.quality = getDesiredQuality(); - if (prefs.quality === 'auto') { - return log(`Desired quality is fine (${prefs.quality})`); - } - - const currentQuality = player.getPlaybackQuality(); - if (prefs.quality === currentQuality) { - return log(`Current quality is desired quality (${currentQuality})`); - } - - const findBestQuality = increase => { - if (prefs.quality === 'highest' || prefs.quality === 'best' || prefs.quality === 'max' || prefs.quality === 'maximum') { - return levels[0]; - } - if (prefs.quality === 'lowest' || prefs.quality === 'worst' || prefs.quality === 'min' || prefs.quality === 'minimum') { - return levels[levels.length - 1]; - } - if (increase) { - prefs.quality = prefs.qualities[prefs.qualities.indexOf(prefs.quality) - 1] || levels[0]; - } - const index = levels.indexOf(prefs.quality); - if (index !== -1) { - return prefs.quality; - } - return findBestQuality(true); - }; - const newBestQuality = findBestQuality(); - if (currentQuality === newBestQuality) { - return log(`Current quality "${currentQuality}" is the best available quality`); - } - - if (!player.setPlaybackQuality) { - return logError('`player.setPlaybackQuality` not available'); + if (succeeded) { + logDebug(`${taskName} succeeded`); + return; + } + attempts--; + logDebug(`${taskName} failed. Remaining attempts ${attempts}`); + if (attempts > 0) { + setTimeout(() => { + this.retry(taskName, task, attempts, interval); + }) + }; + } + // Utility function to replace current URL params and update history. + updateURL(qs) { + const newUrl = `${window.location.pathname}?${qs}`; + window.history.replaceState({}, document.title, newUrl); + logDebug(`update URL to ${newUrl}`); } - player.setPlaybackQuality(newBestQuality); +} - if (!player.setPlaybackQualityRange) { - return logError('`player.setPlaybackQualityRange` not available'); - } - try { - player.setPlaybackQualityRange(newBestQuality, newBestQuality); - } catch (e) { - logError(`Failed to call 'player.setPlaybackQualityRange(${newBestQuality}, ${newBestQuality})' with exception: `, e); - return; +logDebug(`Initializing youtube extension in frame: ${window.location.href}`); +const youtube = new YoutubeExtension(); +youtube.overrideUA(); +window.addEventListener('DOMContentLoaded', () => youtube.overrideViewport()); +window.addEventListener('load', () => { + logDebug('page load'); + youtube.overrideVideoProjection(); + // Wait until video has loaded the first frame to force quality change. + // This prevents the infinite spinner problem. + // See https://github.com/MozillaReality/FirefoxReality/issues/1433 + if (youtube.isWatchingPage()) { + youtube.waitForVideoReady(() => youtube.overrideQualityRetry()); } +}); - log(`Changed quality from "${currentQuality}" to "${newBestQuality}"`); - }; - - window.wrappedJSObject.onYouTubePlayerReady = evt => { - log('`onYouTubePlayerReady` called'); - window.ytImprover(1); - evt.addEventListener('onStateChange', 'ytImprover'); - ytImprover360(true); - }; - - window.addEventListener('spfready', () => { - log('`spfready` event fired'); - if (window.wrappedJSObject.ytplayer && window.wrappedJSObject.ytplayer.config) { - log('`window.ytplayer.config.args.jsapicallback` set'); - window.wrappedJSObject.ytplayer.config.args.jsapicallback = 'onYouTubePlayerReady'; - } - }); -} catch (err) { - console.error(LOGTAG, 'Encountered error:', err); -} +window.addEventListener('pushstate', () => youtube.overrideVideoProjection()); +window.addEventListener('popstate', () => youtube.overrideVideoProjection()); +window.addEventListener('click', event => youtube.overrideClick(event)); diff --git a/app/src/main/assets/web_extensions/webcompat_youtube/manifest.json b/app/src/main/assets/web_extensions/webcompat_youtube/manifest.json index de4ef82c1..34d24e240 100644 --- a/app/src/main/assets/web_extensions/webcompat_youtube/manifest.json +++ b/app/src/main/assets/web_extensions/webcompat_youtube/manifest.json @@ -23,5 +23,4 @@ "background": { "scripts": ["background.js"] } - }