From 73ce4e6bcf557254d7dd230f7635cbd9098a7a9c Mon Sep 17 00:00:00 2001 From: Raymond Hill Date: Wed, 2 Oct 2024 13:39:36 -0400 Subject: [PATCH] Blocking large media elements also prevents autoplay, regardless of size Related issue: https://github.com/uBlockOrigin/uBlock-issues/issues/3394 When the "No large media elements" per-site switch is toggled on, it will also act to prevent autoplay of video/audio media, regardless of their size. This also works for xhr-based media streaming. If blocking by size is not desirable while blocking autoplay is desired, one can toggle on "No large media elements" switch while setting "Block media elements larger than ..." to a very high value. --- platform/chromium/vapi-background-ext.js | 70 +++++---------- platform/common/vapi-background.js | 8 ++ platform/firefox/vapi-background-ext.js | 15 ++-- src/js/pagestore.js | 51 +++++------ .../load-large-media-interactive.js | 85 +++++++++++-------- src/js/traffic.js | 42 ++++++--- 6 files changed, 139 insertions(+), 132 deletions(-) diff --git a/platform/chromium/vapi-background-ext.js b/platform/chromium/vapi-background-ext.js index 29de305846647..9ee69d8199ae5 100644 --- a/platform/chromium/vapi-background-ext.js +++ b/platform/chromium/vapi-background-ext.js @@ -19,10 +19,6 @@ Home: https://github.com/gorhill/uBlock */ -/* globals browser */ - -'use strict'; - /******************************************************************************/ // https://github.com/uBlockOrigin/uBlock-issues/issues/1659 @@ -90,71 +86,47 @@ vAPI.Tabs = class extends vAPI.Tabs { ['gif','image'],['ico','image'],['jpeg','image'],['jpg','image'],['png','image'],['webp','image'] ]); - const headerValue = (headers, name) => { - let i = headers.length; - while ( i-- ) { - if ( headers[i].name.toLowerCase() === name ) { - return headers[i].value.trim(); - } - } - return ''; - }; - const parsedURL = new URL('https://www.example.org/'); - // Extend base class to normalize as per platform. + // Extend base class to normalize as per platform vAPI.Net = class extends vAPI.Net { normalizeDetails(details) { // Chromium 63+ supports the `initiator` property, which contains - // the URL of the origin from which the network request was made. - if ( - typeof details.initiator === 'string' && - details.initiator !== 'null' - ) { + // the URL of the origin from which the network request was made + if ( details.initiator && details.initiator !== 'null' ) { details.documentUrl = details.initiator; } - - let type = details.type; - + const type = details.type; if ( type === 'imageset' ) { details.type = 'image'; return; } - - // The rest of the function code is to normalize type if ( type !== 'other' ) { return; } - - // Try to map known "extension" part of URL to request type. - parsedURL.href = details.url; - const path = parsedURL.pathname, - pos = path.indexOf('.', path.length - 6); - if ( pos !== -1 && (type = extToTypeMap.get(path.slice(pos + 1))) ) { - details.type = type; + // Try to map known "extension" part of URL to request type + if ( details.responseHeaders === undefined ) { + parsedURL.href = details.url; + const path = parsedURL.pathname; + const pos = path.indexOf('.', path.length - 6); + if ( pos !== -1 ) { + details.type = extToTypeMap.get(path.slice(pos + 1)) || type; + } return; } - - // Try to extract type from response headers if present. - if ( details.responseHeaders ) { - type = headerValue(details.responseHeaders, 'content-type'); - if ( type.startsWith('font/') ) { - details.type = 'font'; - return; - } - if ( type.startsWith('image/') ) { - details.type = 'image'; - return; - } - if ( type.startsWith('audio/') || type.startsWith('video/') ) { - details.type = 'media'; - return; - } + // Try to extract type from response headers + const ctype = this.headerValue(details.responseHeaders, 'content-type'); + if ( ctype.startsWith('font/') ) { + details.type = 'font'; + } else if ( ctype.startsWith('image/') ) { + details.type = 'image'; + } else if ( ctype.startsWith('audio/') || ctype.startsWith('video/') ) { + details.type = 'media'; } } // https://www.reddit.com/r/uBlockOrigin/comments/9vcrk3/ // Some types can be mapped from 'other', thus include 'other' if and - // only if the caller is interested in at least one of those types. + // only if the caller is interested in at least one of those types denormalizeTypes(types) { if ( types.length === 0 ) { return Array.from(this.validTypes); diff --git a/platform/common/vapi-background.js b/platform/common/vapi-background.js index 26507982a51e9..fac5121c00082 100644 --- a/platform/common/vapi-background.js +++ b/platform/common/vapi-background.js @@ -1382,6 +1382,14 @@ vAPI.Net = class { if ( this.suspendDepth !== 0 ) { return; } this.unsuspendAllRequests(discard); } + headerValue(headers, name) { + for ( const header of headers ) { + if ( header.name.toLowerCase() === name ) { + return header.value.trim(); + } + } + return ''; + } static canSuspend() { return false; } diff --git a/platform/firefox/vapi-background-ext.js b/platform/firefox/vapi-background-ext.js index 73a914d0ce650..a6032066d9258 100644 --- a/platform/firefox/vapi-background-ext.js +++ b/platform/firefox/vapi-background-ext.js @@ -110,18 +110,15 @@ vAPI.Net = class extends vAPI.Net { details.type = 'image'; return; } + if ( type !== 'object' ) { return; } + // Try to extract type from response headers if present. + if ( details.responseHeaders === undefined ) { return; } + const ctype = this.headerValue(details.responseHeaders, 'content-type'); // https://github.com/uBlockOrigin/uBlock-issues/issues/345 // Re-categorize an embedded object as a `sub_frame` if its // content type is that of a HTML document. - if ( type === 'object' && Array.isArray(details.responseHeaders) ) { - for ( const header of details.responseHeaders ) { - if ( header.name.toLowerCase() === 'content-type' ) { - if ( header.value.startsWith('text/html') ) { - details.type = 'sub_frame'; - } - break; - } - } + if ( ctype === 'text/html' ) { + details.type = 'sub_frame'; } } diff --git a/src/js/pagestore.js b/src/js/pagestore.js index 319367fe51b0d..1b94b1fef6d95 100644 --- a/src/js/pagestore.js +++ b/src/js/pagestore.js @@ -1019,48 +1019,51 @@ const PageStore = class { } // The caller is responsible to check whether filtering is enabled or not. - filterLargeMediaElement(fctxt, size) { + filterLargeMediaElement(fctxt, headers) { fctxt.filter = undefined; - - if ( this.allowLargeMediaElementsUntil === 0 ) { + if ( this.allowLargeMediaElementsUntil === 0 ) { return 0; } + if ( sessionSwitches.evaluateZ('no-large-media', fctxt.getTabHostname() ) !== true ) { + this.allowLargeMediaElementsUntil = 0; return 0; } - // Disregard large media elements previously allowed: for example, to - // seek inside a previously allowed audio/video. - if ( - this.allowLargeMediaElementsRegex instanceof RegExp && - this.allowLargeMediaElementsRegex.test(fctxt.url) - ) { + // XHR-based streaming is never blocked but we want to prevent autoplay + if ( fctxt.itype === fctxt.XMLHTTPREQUEST ) { + const ctype = headers.contentType; + if ( ctype.startsWith('audio/') || ctype.startsWith('video/') ) { + this.largeMediaTimer.on(500); + } return 0; } if ( Date.now() < this.allowLargeMediaElementsUntil ) { - const sources = this.allowLargeMediaElementsRegex instanceof RegExp - ? [ this.allowLargeMediaElementsRegex.source ] - : []; - sources.push('^' + µb.escapeRegex(fctxt.url)); - this.allowLargeMediaElementsRegex = new RegExp(sources.join('|')); + if ( fctxt.itype === fctxt.MEDIA ) { + const sources = this.allowLargeMediaElementsRegex instanceof RegExp + ? [ this.allowLargeMediaElementsRegex.source ] + : []; + sources.push('^' + µb.escapeRegex(fctxt.url)); + this.allowLargeMediaElementsRegex = new RegExp(sources.join('|')); + } return 0; } + // Disregard large media elements previously allowed: for example, to + // seek inside a previously allowed audio/video. if ( - sessionSwitches.evaluateZ( - 'no-large-media', - fctxt.getTabHostname() - ) !== true + this.allowLargeMediaElementsRegex instanceof RegExp && + this.allowLargeMediaElementsRegex.test(fctxt.url) ) { - this.allowLargeMediaElementsUntil = 0; return 0; } - if ( (size >>> 10) < µb.userSettings.largeMediaSize ) { - return 0; + // Regardless of whether a media is blocked, we want to prevent autoplay + if ( fctxt.itype === fctxt.MEDIA ) { + this.largeMediaTimer.on(500); } - + const size = headers.contentLength; + if ( isNaN(size) ) { return 0; } + if ( (size >>> 10) < µb.userSettings.largeMediaSize ) { return 0; } this.largeMediaCount += 1; this.largeMediaTimer.on(500); - if ( logger.enabled ) { fctxt.filter = sessionSwitches.toLogData(); } - return 1; } diff --git a/src/js/scriptlets/load-large-media-interactive.js b/src/js/scriptlets/load-large-media-interactive.js index 4887616d9b0f9..6158124726c70 100644 --- a/src/js/scriptlets/load-large-media-interactive.js +++ b/src/js/scriptlets/load-large-media-interactive.js @@ -28,8 +28,6 @@ if ( typeof vAPI !== 'object' || vAPI.loadAllLargeMedia instanceof Function ) { return; } -/******************************************************************************/ - const largeMediaElementAttribute = 'data-' + vAPI.sessionId; const largeMediaElementSelector = ':root audio[' + largeMediaElementAttribute + '],\n' + @@ -37,25 +35,19 @@ const largeMediaElementSelector = ':root picture[' + largeMediaElementAttribute + '],\n' + ':root video[' + largeMediaElementAttribute + ']'; -/******************************************************************************/ +const isMediaElement = elem => + (/^(?:audio|img|picture|video)$/.test(elem.localName)); -const isMediaElement = function(elem) { - return /^(?:audio|img|picture|video)$/.test(elem.localName); -}; +const isPlayableMediaElement = elem => + (/^(?:audio|video)$/.test(elem.localName)); /******************************************************************************/ const mediaNotLoaded = function(elem) { switch ( elem.localName ) { case 'audio': - case 'video': { - const src = elem.src || ''; - if ( src.startsWith('blob:') ) { - elem.autoplay = false; - elem.pause(); - } + case 'video': return elem.readyState === 0 || elem.error !== null; - } case 'img': { if ( elem.naturalWidth !== 0 || elem.naturalHeight !== 0 ) { break; @@ -99,29 +91,29 @@ const surveyMissingMediaElements = function() { return largeMediaElementCount; }; -if ( surveyMissingMediaElements() === 0 ) { return; } - -// Insert CSS to highlight blocked media elements. -if ( vAPI.largeMediaElementStyleSheet === undefined ) { - vAPI.largeMediaElementStyleSheet = [ - largeMediaElementSelector + ' {', - 'border: 2px dotted red !important;', - 'box-sizing: border-box !important;', - 'cursor: zoom-in !important;', - 'display: inline-block;', - 'filter: none !important;', - 'font-size: 1rem !important;', - 'min-height: 1em !important;', - 'min-width: 1em !important;', - 'opacity: 1 !important;', - 'outline: none !important;', - 'transform: none !important;', - 'visibility: visible !important;', - 'z-index: 2147483647', - '}', - ].join('\n'); - vAPI.userStylesheet.add(vAPI.largeMediaElementStyleSheet); - vAPI.userStylesheet.apply(); +if ( surveyMissingMediaElements() ) { + // Insert CSS to highlight blocked media elements. + if ( vAPI.largeMediaElementStyleSheet === undefined ) { + vAPI.largeMediaElementStyleSheet = [ + largeMediaElementSelector + ' {', + 'border: 2px dotted red !important;', + 'box-sizing: border-box !important;', + 'cursor: zoom-in !important;', + 'display: inline-block;', + 'filter: none !important;', + 'font-size: 1rem !important;', + 'min-height: 1em !important;', + 'min-width: 1em !important;', + 'opacity: 1 !important;', + 'outline: none !important;', + 'transform: none !important;', + 'visibility: visible !important;', + 'z-index: 2147483647', + '}', + ].join('\n'); + vAPI.userStylesheet.add(vAPI.largeMediaElementStyleSheet); + vAPI.userStylesheet.apply(); + } } /******************************************************************************/ @@ -258,6 +250,27 @@ document.addEventListener('error', onLoadError, true); /******************************************************************************/ +const autoPausedMedia = new WeakMap(); + +for ( const elem of document.querySelectorAll('audio,video') ) { + elem.setAttribute('autoplay', 'false'); +} + +const preventAutoplay = function(ev) { + const elem = ev.target; + if ( isPlayableMediaElement(elem) === false ) { return; } + const currentSrc = elem.getAttribute('src') || ''; + const pausedSrc = autoPausedMedia.get(elem); + if ( pausedSrc === currentSrc ) { return; } + autoPausedMedia.set(elem, currentSrc); + elem.setAttribute('autoplay', 'false'); + elem.pause(); +}; + +document.addEventListener('timeupdate', preventAutoplay, true); + +/******************************************************************************/ + vAPI.loadAllLargeMedia = function() { document.removeEventListener('click', onMouseClick, true); document.removeEventListener('loadeddata', onLoadedData, true); diff --git a/src/js/traffic.js b/src/js/traffic.js index 55538fa7e425e..5de002797671e 100644 --- a/src/js/traffic.js +++ b/src/js/traffic.js @@ -488,7 +488,7 @@ const onHeadersReceived = function(details) { } if ( pageStore.getNetFilteringSwitch(fctxt) === false ) { return; } - if ( fctxt.itype === fctxt.IMAGE || fctxt.itype === fctxt.MEDIA ) { + if ( (fctxt.itype & foilLargeMediaElement.TYPE_BITS) !== 0 ) { const result = foilLargeMediaElement(details, fctxt, pageStore); if ( result !== undefined ) { return result; } } @@ -1124,15 +1124,12 @@ const injectPP = function(fctxt, pageStore, responseHeaders) { const foilLargeMediaElement = function(details, fctxt, pageStore) { if ( details.fromCache === true ) { return; } - let size = 0; - if ( µb.userSettings.largeMediaSize !== 0 ) { - const headers = details.responseHeaders; - const i = headerIndexFromName('content-length', headers); - if ( i === -1 ) { return; } - size = parseInt(headers[i].value, 10) || 0; - } + onDemandHeaders.setHeaders(details.responseHeaders); + + const result = pageStore.filterLargeMediaElement(fctxt, onDemandHeaders); + + onDemandHeaders.reset(); - const result = pageStore.filterLargeMediaElement(fctxt, size); if ( result === 0 ) { return; } if ( logger.enabled ) { @@ -1142,16 +1139,15 @@ const foilLargeMediaElement = function(details, fctxt, pageStore) { return { cancel: true }; }; +foilLargeMediaElement.TYPE_BITS = fc.IMAGE | fc.MEDIA | fc.XMLHTTPREQUEST; + /******************************************************************************/ // Caller must ensure headerName is normalized to lower case. const headerIndexFromName = function(headerName, headers) { - let i = headers.length; - while ( i-- ) { - if ( headers[i].name.toLowerCase() === headerName ) { - return i; - } + for ( let i = 0, n = headers.length; i < n; i++ ) { + if ( headers[i].name.toLowerCase() === headerName ) { return i; } } return -1; }; @@ -1161,6 +1157,24 @@ const headerValueFromName = function(headerName, headers) { return i !== -1 ? headers[i].value : ''; }; +const onDemandHeaders = { + headers: [], + get contentLength() { + const contentLength = headerValueFromName('content-length', this.headers); + if ( contentLength === '' ) { return Number.NaN; } + return parseInt(contentLength, 10) || 0; + }, + get contentType() { + return headerValueFromName('content-type', this.headers); + }, + setHeaders(headers) { + this.headers = headers; + }, + reset() { + this.headers = []; + } +}; + /******************************************************************************/ const strictBlockBypasser = {