From fb3b57570157e97aa9e8484caa5d4f3cf1b9d975 Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Mon, 31 Mar 2025 12:29:25 +0100 Subject: [PATCH 01/79] Initial structure --- injected/src/features/duckplayer-native.js | 15 ++ .../duckplayer-native/getCurrentTimestamp.js | 4 + .../duckplayer-native/mediaControl.js | 140 ++++++++++++++++++ .../features/duckplayer-native/muteAudio.js | 5 + .../features/duckplayer-native/serpNotify.js | 13 ++ 5 files changed, 177 insertions(+) create mode 100644 injected/src/features/duckplayer-native.js create mode 100644 injected/src/features/duckplayer-native/getCurrentTimestamp.js create mode 100644 injected/src/features/duckplayer-native/mediaControl.js create mode 100644 injected/src/features/duckplayer-native/muteAudio.js create mode 100644 injected/src/features/duckplayer-native/serpNotify.js diff --git a/injected/src/features/duckplayer-native.js b/injected/src/features/duckplayer-native.js new file mode 100644 index 0000000000..2810893247 --- /dev/null +++ b/injected/src/features/duckplayer-native.js @@ -0,0 +1,15 @@ +import ContentFeature from '../content-feature.js'; +import { isBeingFramed } from '../utils.js'; + +export class DuckPlayerNative extends ContentFeature { + init() { + if (this.platform.name !== 'ios') return; + + /** + * This feature never operates in a frame + */ + if (isBeingFramed()) return; + } +} + +export default DuckPlayerNative; diff --git a/injected/src/features/duckplayer-native/getCurrentTimestamp.js b/injected/src/features/duckplayer-native/getCurrentTimestamp.js new file mode 100644 index 0000000000..1c48096f22 --- /dev/null +++ b/injected/src/features/duckplayer-native/getCurrentTimestamp.js @@ -0,0 +1,4 @@ +function getCurrentTime() { + const video = document.querySelector('video'); + return video ? video.currentTime : 0; +} \ No newline at end of file diff --git a/injected/src/features/duckplayer-native/mediaControl.js b/injected/src/features/duckplayer-native/mediaControl.js new file mode 100644 index 0000000000..d3be01d988 --- /dev/null +++ b/injected/src/features/duckplayer-native/mediaControl.js @@ -0,0 +1,140 @@ +(function() { + // Initialize state if not exists + if (!window._mediaControlState) { + window._mediaControlState = { + observer: null, + userInitiated: false, + originalPlay: HTMLMediaElement.prototype.play, + originalLoad: HTMLMediaElement.prototype.load, + isPaused: false + }; + } + const state = window._mediaControlState; + + // Block playback handler + const blockPlayback = function(event) { + event.preventDefault(); + event.stopPropagation(); + return false; + }; + + // The actual media control function + function mediaControl(pause) { + state.isPaused = pause; + + if (pause) { + // Capture play events at the earliest possible moment + document.addEventListener('play', blockPlayback, true); + document.addEventListener('playing', blockPlayback, true); + + // Block HTML5 video/audio playback methods + HTMLMediaElement.prototype.play = function() { + this.pause(); + return Promise.reject(new Error('Playback blocked')); + }; + + // Override load to ensure media starts paused + HTMLMediaElement.prototype.load = function() { + this.autoplay = false; + this.pause(); + return state.originalLoad.apply(this, arguments); + }; + + // Listen for user interactions that may lead to playback + document.addEventListener('touchstart', () => { + state.userInitiated = true; + + // Remove the early blocking listeners + document.removeEventListener('play', blockPlayback, true); + document.removeEventListener('playing', blockPlayback, true); + + // Reset HTMLMediaElement.prototype.play + HTMLMediaElement.prototype.play = state.originalPlay; + + // Unmute all media elements when user interacts + document.querySelectorAll('audio, video').forEach(media => { + media.muted = false; + }); + + // Reset after a short delay + setTimeout(() => { + state.userInitiated = false; + + // Re-add blocking if still in paused state + if (state.isPaused) { + document.addEventListener('play', blockPlayback, true); + document.addEventListener('playing', blockPlayback, true); + HTMLMediaElement.prototype.play = function() { + this.pause(); + return Promise.reject(new Error('Playback blocked')); + }; + } + }, 500); + }, true); + + // Initial pause of all media + document.querySelectorAll('audio, video').forEach(media => { + media.pause(); + media.muted = true; + media.autoplay = false; + }); + + // Monitor DOM for newly added media elements + if (state.observer) { + state.observer.disconnect(); + } + + state.observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + // Check for added nodes + mutation.addedNodes.forEach(node => { + if (node.tagName === 'VIDEO' || node.tagName === 'AUDIO') { + if (!state.userInitiated) { + node.pause(); + node.muted = true; + node.autoplay = false; + } + } else if (node.querySelectorAll) { + node.querySelectorAll('audio, video').forEach(media => { + if (!state.userInitiated) { + media.pause(); + media.muted = true; + media.autoplay = false; + } + }); + } + }); + }); + }); + + state.observer.observe(document.documentElement || document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['autoplay', 'src', 'playing'] + }); + } else { + // Restore original methods + HTMLMediaElement.prototype.play = state.originalPlay; + HTMLMediaElement.prototype.load = state.originalLoad; + + // Remove listeners + document.removeEventListener('play', blockPlayback, true); + document.removeEventListener('playing', blockPlayback, true); + + // Clean up observer + if (state.observer) { + state.observer.disconnect(); + state.observer = null; + } + + // Unmute all media + document.querySelectorAll('audio, video').forEach(media => { + media.muted = false; + }); + } + } + + // Export function + window.mediaControl = mediaControl; +})(); \ No newline at end of file diff --git a/injected/src/features/duckplayer-native/muteAudio.js b/injected/src/features/duckplayer-native/muteAudio.js new file mode 100644 index 0000000000..5ecb0e95e2 --- /dev/null +++ b/injected/src/features/duckplayer-native/muteAudio.js @@ -0,0 +1,5 @@ +function muteAudio(mute) { + document.querySelectorAll('audio, video').forEach(media => { + media.muted = mute; + }); +} \ No newline at end of file diff --git a/injected/src/features/duckplayer-native/serpNotify.js b/injected/src/features/duckplayer-native/serpNotify.js new file mode 100644 index 0000000000..f2c8d57181 --- /dev/null +++ b/injected/src/features/duckplayer-native/serpNotify.js @@ -0,0 +1,13 @@ +window.dispatchEvent( + new CustomEvent('ddg-serp-yt-response', { + detail: { + kind: 'initialSetup', + data: { + privatePlayerMode: { enabled: {} }, + overlayInteracted: false, + } + }, + composed: true, + bubbles: true, + }), +) \ No newline at end of file From 19f9a06d3ec7a4961d9ef69cebdeac29944af8c9 Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Mon, 31 Mar 2025 14:13:26 +0100 Subject: [PATCH 02/79] Duck Player Native feature --- injected/src/features.js | 13 ++- injected/src/features/duck-player-native.js | 25 ++++ injected/src/features/duckplayer-native.js | 15 --- .../features/duckplayer-native/constants.js | 10 ++ .../duckplayer-native/duckplayer-native.js | 45 ++++++++ .../duckplayer-native/getCurrentTimestamp.js | 7 +- .../duckplayer-native/mediaControl.js | 107 ++++++++++-------- .../features/duckplayer-native/muteAudio.js | 9 +- .../duckplayer-native/native-messages.js | 71 ++++++++++++ .../features/duckplayer-native/serpNotify.js | 31 ++--- 10 files changed, 248 insertions(+), 85 deletions(-) create mode 100644 injected/src/features/duck-player-native.js delete mode 100644 injected/src/features/duckplayer-native.js create mode 100644 injected/src/features/duckplayer-native/constants.js create mode 100644 injected/src/features/duckplayer-native/duckplayer-native.js create mode 100644 injected/src/features/duckplayer-native/native-messages.js diff --git a/injected/src/features.js b/injected/src/features.js index 9809ca4925..96a2b07728 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -19,6 +19,7 @@ const otherFeatures = /** @type {const} */ ([ 'cookie', 'messageBridge', 'duckPlayer', + 'duckPlayerNative', 'harmfulApis', 'webCompat', 'windowsPermissionUsage', @@ -32,8 +33,16 @@ const otherFeatures = /** @type {const} */ ([ /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ /** @type {Record} */ export const platformSupport = { - apple: ['webCompat', ...baseFeatures], - 'apple-isolated': ['duckPlayer', 'brokerProtection', 'performanceMetrics', 'clickToLoad', 'messageBridge', 'favicon'], + apple: ['webCompat', 'duckPlayerNative', ...baseFeatures], + 'apple-isolated': [ + 'duckPlayer', + 'duckPlayerNative', + 'brokerProtection', + 'performanceMetrics', + 'clickToLoad', + 'messageBridge', + 'favicon', + ], android: [...baseFeatures, 'webCompat', 'breakageReporting', 'duckPlayer', 'messageBridge'], 'android-broker-protection': ['brokerProtection'], 'android-autofill-password-import': ['autofillPasswordImport'], diff --git a/injected/src/features/duck-player-native.js b/injected/src/features/duck-player-native.js new file mode 100644 index 0000000000..753d362b2d --- /dev/null +++ b/injected/src/features/duck-player-native.js @@ -0,0 +1,25 @@ +import ContentFeature from '../content-feature.js'; +import { isBeingFramed } from '../utils.js'; +import { DuckPlayerNativeMessages } from './duckplayer-native/native-messages.js'; +import { initDuckPlayerNative } from './duckplayer-native/duckplayer-native.js'; + +/** + * @typedef InitialSettings - The initial payload used to communicate render-blocking information + * @property {string} version - TODO: this is only here to test config. Replace with actual settings. + */ + +export class DuckPlayerNative extends ContentFeature { + init() { + if (this.platform.name !== 'ios') return; + + /** + * This feature never operates in a frame + */ + if (isBeingFramed()) return; + + const comms = new DuckPlayerNativeMessages(this.messaging); + initDuckPlayerNative(comms); + } +} + +export default DuckPlayerNative; diff --git a/injected/src/features/duckplayer-native.js b/injected/src/features/duckplayer-native.js deleted file mode 100644 index 2810893247..0000000000 --- a/injected/src/features/duckplayer-native.js +++ /dev/null @@ -1,15 +0,0 @@ -import ContentFeature from '../content-feature.js'; -import { isBeingFramed } from '../utils.js'; - -export class DuckPlayerNative extends ContentFeature { - init() { - if (this.platform.name !== 'ios') return; - - /** - * This feature never operates in a frame - */ - if (isBeingFramed()) return; - } -} - -export default DuckPlayerNative; diff --git a/injected/src/features/duckplayer-native/constants.js b/injected/src/features/duckplayer-native/constants.js new file mode 100644 index 0000000000..29e40718c7 --- /dev/null +++ b/injected/src/features/duckplayer-native/constants.js @@ -0,0 +1,10 @@ +export const MSG_NAME_INITIAL_SETUP = 'initialSetup'; +export const MSG_NAME_SET_VALUES = 'setUserValues'; +export const MSG_NAME_READ_VALUES = 'getUserValues'; +export const MSG_NAME_READ_VALUES_SERP = 'readUserValues'; +export const MSG_NAME_OPEN_PLAYER = 'openDuckPlayer'; +export const MSG_NAME_OPEN_INFO = 'openInfo'; +export const MSG_NAME_PUSH_DATA = 'onUserValuesChanged'; +export const MSG_NAME_PIXEL = 'sendDuckPlayerPixel'; +export const MSG_NAME_PROXY_INCOMING = 'ddg-serp-yt'; +export const MSG_NAME_PROXY_RESPONSE = 'ddg-serp-yt-response'; diff --git a/injected/src/features/duckplayer-native/duckplayer-native.js b/injected/src/features/duckplayer-native/duckplayer-native.js new file mode 100644 index 0000000000..759cd2ffae --- /dev/null +++ b/injected/src/features/duckplayer-native/duckplayer-native.js @@ -0,0 +1,45 @@ +import { getCurrentTimestamp } from './getCurrentTimestamp.js'; +import { mediaControl } from './mediaControl.js'; +import { muteAudio } from './muteAudio.js'; +import { serpNotify } from './serpNotify.js'; + +/** + * + * @param {import('./native-messages.js').DuckPlayerNativeMessages} messages + * @returns + */ +export async function initDuckPlayerNative(messages) { + /** @type {import("../duck-player-native.js").InitialSettings} */ + let initialSetup; + try { + initialSetup = await messages.initialSetup(); + } catch (e) { + console.error(e); + return; + } + + console.log('INITIAL SETUP', initialSetup); + + /** + * Set up subscription listeners + */ + messages.onGetCurrentTimestamp(() => { + console.log('GET CURRENT TIMESTAMP'); + getCurrentTimestamp(); + }); + + messages.onMediaControl(() => { + console.log('MEDIA CONTROL'); + mediaControl(); + }); + + messages.onMuteAudio((mute) => { + console.log('MUTE AUDIO', mute); + muteAudio(mute); + }); + + messages.onSerpNotify(() => { + console.log('SERP PROXY'); + serpNotify(); + }); +} diff --git a/injected/src/features/duckplayer-native/getCurrentTimestamp.js b/injected/src/features/duckplayer-native/getCurrentTimestamp.js index 1c48096f22..7435cda759 100644 --- a/injected/src/features/duckplayer-native/getCurrentTimestamp.js +++ b/injected/src/features/duckplayer-native/getCurrentTimestamp.js @@ -1,4 +1,7 @@ -function getCurrentTime() { +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck - Typing will be fixed in the future + +export function getCurrentTimestamp() { const video = document.querySelector('video'); return video ? video.currentTime : 0; -} \ No newline at end of file +} diff --git a/injected/src/features/duckplayer-native/mediaControl.js b/injected/src/features/duckplayer-native/mediaControl.js index d3be01d988..39c319a3d1 100644 --- a/injected/src/features/duckplayer-native/mediaControl.js +++ b/injected/src/features/duckplayer-native/mediaControl.js @@ -1,4 +1,7 @@ -(function() { +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck - Typing will be fixed in the future + +export function mediaControl() { // Initialize state if not exists if (!window._mediaControlState) { window._mediaControlState = { @@ -6,13 +9,13 @@ userInitiated: false, originalPlay: HTMLMediaElement.prototype.play, originalLoad: HTMLMediaElement.prototype.load, - isPaused: false + isPaused: false, }; } const state = window._mediaControlState; // Block playback handler - const blockPlayback = function(event) { + const blockPlayback = function (event) { event.preventDefault(); event.stopPropagation(); return false; @@ -21,59 +24,63 @@ // The actual media control function function mediaControl(pause) { state.isPaused = pause; - + if (pause) { // Capture play events at the earliest possible moment document.addEventListener('play', blockPlayback, true); document.addEventListener('playing', blockPlayback, true); - + // Block HTML5 video/audio playback methods - HTMLMediaElement.prototype.play = function() { + HTMLMediaElement.prototype.play = function () { this.pause(); return Promise.reject(new Error('Playback blocked')); }; - + // Override load to ensure media starts paused - HTMLMediaElement.prototype.load = function() { + HTMLMediaElement.prototype.load = function () { this.autoplay = false; this.pause(); return state.originalLoad.apply(this, arguments); }; // Listen for user interactions that may lead to playback - document.addEventListener('touchstart', () => { - state.userInitiated = true; - - // Remove the early blocking listeners - document.removeEventListener('play', blockPlayback, true); - document.removeEventListener('playing', blockPlayback, true); - - // Reset HTMLMediaElement.prototype.play - HTMLMediaElement.prototype.play = state.originalPlay; - - // Unmute all media elements when user interacts - document.querySelectorAll('audio, video').forEach(media => { - media.muted = false; - }); + document.addEventListener( + 'touchstart', + () => { + state.userInitiated = true; + + // Remove the early blocking listeners + document.removeEventListener('play', blockPlayback, true); + document.removeEventListener('playing', blockPlayback, true); + + // Reset HTMLMediaElement.prototype.play + HTMLMediaElement.prototype.play = state.originalPlay; - // Reset after a short delay - setTimeout(() => { - state.userInitiated = false; - - // Re-add blocking if still in paused state - if (state.isPaused) { - document.addEventListener('play', blockPlayback, true); - document.addEventListener('playing', blockPlayback, true); - HTMLMediaElement.prototype.play = function() { - this.pause(); - return Promise.reject(new Error('Playback blocked')); - }; - } - }, 500); - }, true); + // Unmute all media elements when user interacts + document.querySelectorAll('audio, video').forEach((media) => { + media.muted = false; + }); + + // Reset after a short delay + setTimeout(() => { + state.userInitiated = false; + + // Re-add blocking if still in paused state + if (state.isPaused) { + document.addEventListener('play', blockPlayback, true); + document.addEventListener('playing', blockPlayback, true); + HTMLMediaElement.prototype.play = function () { + this.pause(); + return Promise.reject(new Error('Playback blocked')); + }; + } + }, 500); + }, + true, + ); // Initial pause of all media - document.querySelectorAll('audio, video').forEach(media => { + document.querySelectorAll('audio, video').forEach((media) => { media.pause(); media.muted = true; media.autoplay = false; @@ -83,11 +90,11 @@ if (state.observer) { state.observer.disconnect(); } - - state.observer = new MutationObserver(mutations => { - mutations.forEach(mutation => { + + state.observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { // Check for added nodes - mutation.addedNodes.forEach(node => { + mutation.addedNodes.forEach((node) => { if (node.tagName === 'VIDEO' || node.tagName === 'AUDIO') { if (!state.userInitiated) { node.pause(); @@ -95,7 +102,7 @@ node.autoplay = false; } } else if (node.querySelectorAll) { - node.querySelectorAll('audio, video').forEach(media => { + node.querySelectorAll('audio, video').forEach((media) => { if (!state.userInitiated) { media.pause(); media.muted = true; @@ -107,21 +114,21 @@ }); }); - state.observer.observe(document.documentElement || document.body, { - childList: true, + state.observer.observe(document.documentElement || document.body, { + childList: true, subtree: true, attributes: true, - attributeFilter: ['autoplay', 'src', 'playing'] + attributeFilter: ['autoplay', 'src', 'playing'], }); } else { // Restore original methods HTMLMediaElement.prototype.play = state.originalPlay; HTMLMediaElement.prototype.load = state.originalLoad; - + // Remove listeners document.removeEventListener('play', blockPlayback, true); document.removeEventListener('playing', blockPlayback, true); - + // Clean up observer if (state.observer) { state.observer.disconnect(); @@ -129,7 +136,7 @@ } // Unmute all media - document.querySelectorAll('audio, video').forEach(media => { + document.querySelectorAll('audio, video').forEach((media) => { media.muted = false; }); } @@ -137,4 +144,4 @@ // Export function window.mediaControl = mediaControl; -})(); \ No newline at end of file +} diff --git a/injected/src/features/duckplayer-native/muteAudio.js b/injected/src/features/duckplayer-native/muteAudio.js index 5ecb0e95e2..7ad265f160 100644 --- a/injected/src/features/duckplayer-native/muteAudio.js +++ b/injected/src/features/duckplayer-native/muteAudio.js @@ -1,5 +1,8 @@ -function muteAudio(mute) { - document.querySelectorAll('audio, video').forEach(media => { +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck - Typing will be fixed in the future + +export function muteAudio(mute) { + document.querySelectorAll('audio, video').forEach((media) => { media.muted = mute; }); -} \ No newline at end of file +} diff --git a/injected/src/features/duckplayer-native/native-messages.js b/injected/src/features/duckplayer-native/native-messages.js new file mode 100644 index 0000000000..8ff1e41ed5 --- /dev/null +++ b/injected/src/features/duckplayer-native/native-messages.js @@ -0,0 +1,71 @@ +import * as constants from './constants.js'; + +/** + * @typedef {import("@duckduckgo/messaging").Messaging} Messaging + * + * A wrapper for all communications. + * + * Please see https://duckduckgo.github.io/content-scope-utils/modules/Webkit_Messaging for the underlying + * messaging primitives. + */ +export class DuckPlayerNativeMessages { + /** + * @param {Messaging} messaging + * @internal + */ + constructor(messaging) { + /** + * @internal + */ + this.messaging = messaging; + // this.environment = environment; + // TODO: Replace with class if needed + this.environment = { + isIntegrationMode: function () { + return true; + }, + }; + } + + /** + * @returns {Promise} + */ + initialSetup() { + if (this.environment.isIntegrationMode()) { + return Promise.resolve({ version: '1' }); + } + return this.messaging.request(constants.MSG_NAME_INITIAL_SETUP); + } + + /** + * Subscribe to get current timestamp events + * @param {() => void} callback + */ + onGetCurrentTimestamp(callback) { + return this.messaging.subscribe('onGetCurrentTimestamp', callback); + } + + /** + * Subscribe to media control events + * @param {() => void} callback + */ + onMediaControl(callback) { + return this.messaging.subscribe('onMediaControl', callback); + } + + /** + * Subscribe to mute audio events + * @param {(mute: boolean) => void} callback + */ + onMuteAudio(callback) { + return this.messaging.subscribe('onMuteAudio', callback); + } + + /** + * Subscribe to serp proxy events + * @param {() => void} callback + */ + onSerpNotify(callback) { + return this.messaging.subscribe('onSerpNotify', callback); + } +} diff --git a/injected/src/features/duckplayer-native/serpNotify.js b/injected/src/features/duckplayer-native/serpNotify.js index f2c8d57181..a5dfa9d094 100644 --- a/injected/src/features/duckplayer-native/serpNotify.js +++ b/injected/src/features/duckplayer-native/serpNotify.js @@ -1,13 +1,18 @@ -window.dispatchEvent( - new CustomEvent('ddg-serp-yt-response', { - detail: { - kind: 'initialSetup', - data: { - privatePlayerMode: { enabled: {} }, - overlayInteracted: false, - } - }, - composed: true, - bubbles: true, - }), -) \ No newline at end of file +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck - Typing will be fixed in the future + +export function serpNotify() { + window.dispatchEvent( + new CustomEvent('ddg-serp-yt-response', { + detail: { + kind: 'initialSetup', + data: { + privatePlayerMode: { enabled: {} }, + overlayInteracted: false, + }, + }, + composed: true, + bubbles: true, + }), + ); +} From 81f58fd211e6bc7046e8cad7e0c4573a304d269a Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Mon, 31 Mar 2025 18:22:52 +0100 Subject: [PATCH 03/79] Error detection --- .../duckplayer-native/duckplayer-native.js | 21 ++- .../duckplayer-native/error-detection.js | 132 ++++++++++++++++++ ...tTimestamp.js => get-current-timestamp.js} | 0 .../{mediaControl.js => media-control.js} | 0 .../{muteAudio.js => mute-audio.js} | 0 .../duckplayer-native/native-messages.js | 8 ++ .../{serpNotify.js => serp-notify.js} | 0 7 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 injected/src/features/duckplayer-native/error-detection.js rename injected/src/features/duckplayer-native/{getCurrentTimestamp.js => get-current-timestamp.js} (100%) rename injected/src/features/duckplayer-native/{mediaControl.js => media-control.js} (100%) rename injected/src/features/duckplayer-native/{muteAudio.js => mute-audio.js} (100%) rename injected/src/features/duckplayer-native/{serpNotify.js => serp-notify.js} (100%) diff --git a/injected/src/features/duckplayer-native/duckplayer-native.js b/injected/src/features/duckplayer-native/duckplayer-native.js index 759cd2ffae..f493e35e52 100644 --- a/injected/src/features/duckplayer-native/duckplayer-native.js +++ b/injected/src/features/duckplayer-native/duckplayer-native.js @@ -1,7 +1,8 @@ -import { getCurrentTimestamp } from './getCurrentTimestamp.js'; -import { mediaControl } from './mediaControl.js'; -import { muteAudio } from './muteAudio.js'; -import { serpNotify } from './serpNotify.js'; +import { getCurrentTimestamp } from './get-current-timestamp.js'; +import { mediaControl } from './media-control.js'; +import { muteAudio } from './mute-audio.js'; +import { serpNotify } from './serp-notify.js'; +import { ErrorDetection } from './error-detection.js'; /** * @@ -11,6 +12,9 @@ import { serpNotify } from './serpNotify.js'; export async function initDuckPlayerNative(messages) { /** @type {import("../duck-player-native.js").InitialSettings} */ let initialSetup; + /** @type {(() => void|null)[]} */ + const sideEffects = []; + try { initialSetup = await messages.initialSetup(); } catch (e) { @@ -42,4 +46,13 @@ export async function initDuckPlayerNative(messages) { console.log('SERP PROXY'); serpNotify(); }); + + /* Start error detection */ + const errorDetection = new ErrorDetection(messages); + const destroy = errorDetection.observe(); + if (destroy) sideEffects.push(destroy); + + return async () => { + return await Promise.all(sideEffects.map((destroy) => destroy())); + }; } diff --git a/injected/src/features/duckplayer-native/error-detection.js b/injected/src/features/duckplayer-native/error-detection.js new file mode 100644 index 0000000000..1f194aeeef --- /dev/null +++ b/injected/src/features/duckplayer-native/error-detection.js @@ -0,0 +1,132 @@ +/** @typedef {"age-restricted" | "sign-in-required" | "no-embed" | "unknown"} YouTubeError */ + +/** @type {Record} */ +export const YOUTUBE_ERRORS = { + ageRestricted: 'age-restricted', + signInRequired: 'sign-in-required', + noEmbed: 'no-embed', + unknown: 'unknown', +}; + +/** + * Detects YouTube errors based on DOM queries + */ +export class ErrorDetection { + /** @type {import('./native-messages.js').DuckPlayerNativeMessages} */ + messages; + + constructor(messages) { + this.messages = messages; + this.settings = { + // TODO: Get settings from native + signInRequiredSelector: '[href*="//support.google.com/youtube/answer/3037019"]', + }; + this.observe(); + } + + observe() { + console.log('Setting up error detection...'); + const documentBody = document?.body; + if (documentBody) { + // Check if iframe already contains error + if (this.checkForError(documentBody)) { + const error = this.getErrorType(); + this.messages.onYoutubeError(error); + return null; + } + + // Create a MutationObserver instance + const observer = new MutationObserver(this.handleMutation.bind(this)); + + // Start observing the iframe's document for changes + observer.observe(documentBody, { + childList: true, + subtree: true, // Observe all descendants of the body + }); + + return () => { + observer.disconnect(); + }; + } + + return null; + } + + /** + * Mutation handler that checks new nodes for error states + * + * @type {MutationCallback} + */ + handleMutation(mutationsList) { + for (const mutation of mutationsList) { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach((node) => { + if (this.checkForError(node)) { + console.log('A node with an error has been added to the document:', node); + const error = this.getErrorType(); + this.messages.onYoutubeError(error); + } + }); + } + } + } + + /** + * Attempts to detect the type of error in the YouTube embed iframe + * @returns {YouTubeError} + */ + getErrorType() { + const currentWindow = /** @type {Window & typeof globalThis & { ytcfg: object }} */ (window); + let playerResponse; + + try { + playerResponse = JSON.parse(currentWindow.ytcfg?.get('PLAYER_VARS')?.embedded_player_response); + } catch (e) { + console.log('Could not parse player response', e); + } + + if (typeof playerResponse === 'object') { + const { + previewPlayabilityStatus: { desktopLegacyAgeGateReason, status }, + } = playerResponse; + + // 1. Check for UNPLAYABLE status + if (status === 'UNPLAYABLE') { + // 1.1. Check for presence of desktopLegacyAgeGateReason + if (desktopLegacyAgeGateReason === 1) { + return YOUTUBE_ERRORS.ageRestricted; + } + + // 1.2. Fall back to embed not allowed error + return YOUTUBE_ERRORS.noEmbed; + } + + // 2. Check for sign-in support link + try { + if (this.settings?.signInRequiredSelector && !!document.querySelector(this.settings.signInRequiredSelector)) { + return YOUTUBE_ERRORS.signInRequired; + } + } catch (e) { + console.log('Sign-in required query failed', e); + } + } + + // 3. Fall back to unknown error + return YOUTUBE_ERRORS.unknown; + } + + /** + * Analyses a node and its children to determine if it contains an error state + * + * @param {Node} [node] + */ + checkForError(node) { + if (node?.nodeType === Node.ELEMENT_NODE) { + const element = /** @type {HTMLElement} */ (node); + // Check if element has the error class or contains any children with that class + return element.classList.contains('ytp-error') || !!element.querySelector('.ytp-error'); + } + + return false; + } +} diff --git a/injected/src/features/duckplayer-native/getCurrentTimestamp.js b/injected/src/features/duckplayer-native/get-current-timestamp.js similarity index 100% rename from injected/src/features/duckplayer-native/getCurrentTimestamp.js rename to injected/src/features/duckplayer-native/get-current-timestamp.js diff --git a/injected/src/features/duckplayer-native/mediaControl.js b/injected/src/features/duckplayer-native/media-control.js similarity index 100% rename from injected/src/features/duckplayer-native/mediaControl.js rename to injected/src/features/duckplayer-native/media-control.js diff --git a/injected/src/features/duckplayer-native/muteAudio.js b/injected/src/features/duckplayer-native/mute-audio.js similarity index 100% rename from injected/src/features/duckplayer-native/muteAudio.js rename to injected/src/features/duckplayer-native/mute-audio.js diff --git a/injected/src/features/duckplayer-native/native-messages.js b/injected/src/features/duckplayer-native/native-messages.js index 8ff1e41ed5..03fb79a3fb 100644 --- a/injected/src/features/duckplayer-native/native-messages.js +++ b/injected/src/features/duckplayer-native/native-messages.js @@ -68,4 +68,12 @@ export class DuckPlayerNativeMessages { onSerpNotify(callback) { return this.messaging.subscribe('onSerpNotify', callback); } + + /** + * Notifies browser of YouTube error + * @param {string} error + */ + onYoutubeError(error) { + this.messaging.notify('onYoutubeError', { error }); + } } diff --git a/injected/src/features/duckplayer-native/serpNotify.js b/injected/src/features/duckplayer-native/serp-notify.js similarity index 100% rename from injected/src/features/duckplayer-native/serpNotify.js rename to injected/src/features/duckplayer-native/serp-notify.js From 424c0b2a91c0ad9e8c222ac4f957b10a8f3e7fbd Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Fri, 4 Apr 2025 18:33:37 +0100 Subject: [PATCH 04/79] Switching to notifications --- .../duckplayer-native/duckplayer-native.js | 18 +++++++++++++----- .../duckplayer-native/get-current-timestamp.js | 8 ++++---- .../duckplayer-native/native-messages.js | 15 ++++++++++----- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/injected/src/features/duckplayer-native/duckplayer-native.js b/injected/src/features/duckplayer-native/duckplayer-native.js index f493e35e52..5247c5a4fd 100644 --- a/injected/src/features/duckplayer-native/duckplayer-native.js +++ b/injected/src/features/duckplayer-native/duckplayer-native.js @@ -27,17 +27,17 @@ export async function initDuckPlayerNative(messages) { /** * Set up subscription listeners */ - messages.onGetCurrentTimestamp(() => { - console.log('GET CURRENT TIMESTAMP'); - getCurrentTimestamp(); - }); + // messages.onGetCurrentTimestamp(() => { + // console.log('GET CURRENT TIMESTAMP'); + // getCurrentTimestamp(); + // }); messages.onMediaControl(() => { console.log('MEDIA CONTROL'); mediaControl(); }); - messages.onMuteAudio((mute) => { + messages.onMuteAudio(({ mute }) => { console.log('MUTE AUDIO', mute); muteAudio(mute); }); @@ -52,6 +52,14 @@ export async function initDuckPlayerNative(messages) { const destroy = errorDetection.observe(); if (destroy) sideEffects.push(destroy); + /* Start timestamp polling */ + const timestampPolling = setInterval(() => { + messages.onGetCurrentTimestamp(getCurrentTimestamp()); + }); + sideEffects.push(() => { + clearInterval(timestampPolling); + }); + return async () => { return await Promise.all(sideEffects.map((destroy) => destroy())); }; diff --git a/injected/src/features/duckplayer-native/get-current-timestamp.js b/injected/src/features/duckplayer-native/get-current-timestamp.js index 7435cda759..32106e253b 100644 --- a/injected/src/features/duckplayer-native/get-current-timestamp.js +++ b/injected/src/features/duckplayer-native/get-current-timestamp.js @@ -1,7 +1,7 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck - Typing will be fixed in the future - +/** + * @returns {number} + */ export function getCurrentTimestamp() { const video = document.querySelector('video'); - return video ? video.currentTime : 0; + return video?.currentTime || 0; } diff --git a/injected/src/features/duckplayer-native/native-messages.js b/injected/src/features/duckplayer-native/native-messages.js index 03fb79a3fb..5ebb9f29e3 100644 --- a/injected/src/features/duckplayer-native/native-messages.js +++ b/injected/src/features/duckplayer-native/native-messages.js @@ -1,5 +1,10 @@ import * as constants from './constants.js'; +/** + * @typedef {object} muteSettings - Settings passing to the onMute callback + * @property {boolean} mute - Set to true to mute the video, false to unmute + */ + /** * @typedef {import("@duckduckgo/messaging").Messaging} Messaging * @@ -38,11 +43,11 @@ export class DuckPlayerNativeMessages { } /** - * Subscribe to get current timestamp events - * @param {() => void} callback + * Notifies with current timestamp + * @param {number} timestamp */ - onGetCurrentTimestamp(callback) { - return this.messaging.subscribe('onGetCurrentTimestamp', callback); + onGetCurrentTimestamp(timestamp) { + return this.messaging.notify('onGetCurrentTimestamp', { timestamp }); } /** @@ -55,7 +60,7 @@ export class DuckPlayerNativeMessages { /** * Subscribe to mute audio events - * @param {(mute: boolean) => void} callback + * @param {(muteSettings: muteSettings) => void} callback */ onMuteAudio(callback) { return this.messaging.subscribe('onMuteAudio', callback); From 154237a771de2fadb57575000ea570fa82f1b03a Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Fri, 4 Apr 2025 18:45:59 +0100 Subject: [PATCH 05/79] Interval --- injected/src/features/duckplayer-native/duckplayer-native.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/injected/src/features/duckplayer-native/duckplayer-native.js b/injected/src/features/duckplayer-native/duckplayer-native.js index 5247c5a4fd..90ed097ced 100644 --- a/injected/src/features/duckplayer-native/duckplayer-native.js +++ b/injected/src/features/duckplayer-native/duckplayer-native.js @@ -55,7 +55,8 @@ export async function initDuckPlayerNative(messages) { /* Start timestamp polling */ const timestampPolling = setInterval(() => { messages.onGetCurrentTimestamp(getCurrentTimestamp()); - }); + }, 300); + sideEffects.push(() => { clearInterval(timestampPolling); }); From 33530829ffd927043683b777f5e9c271e504fd5f Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Mon, 7 Apr 2025 19:46:05 +0100 Subject: [PATCH 06/79] Feature setup --- injected/entry-points/integration.js | 2 +- .../duckplayer/pages/player-native.html | 251 ++++++++++++++++++ injected/src/features.js | 1 - injected/src/features/duck-player-native.js | 10 +- 4 files changed, 257 insertions(+), 7 deletions(-) create mode 100644 injected/integration-test/test-pages/duckplayer/pages/player-native.html diff --git a/injected/entry-points/integration.js b/injected/entry-points/integration.js index 7938b7d21c..5a63e47f09 100644 --- a/injected/entry-points/integration.js +++ b/injected/entry-points/integration.js @@ -46,7 +46,7 @@ function generateConfig() { 'cookie', 'webCompat', 'apiManipulation', - 'duckPlayer', + 'duckPlayerNative', ], }, }; diff --git a/injected/integration-test/test-pages/duckplayer/pages/player-native.html b/injected/integration-test/test-pages/duckplayer/pages/player-native.html new file mode 100644 index 0000000000..297614e5a4 --- /dev/null +++ b/injected/integration-test/test-pages/duckplayer/pages/player-native.html @@ -0,0 +1,251 @@ + + + + + + Duck Player - Player Overlay + + + + + +

[Duck Player]

+ +
+
+ + +
+ +
+ + + +
+
+ +
+ + + + + + + + + + diff --git a/injected/src/features.js b/injected/src/features.js index 96a2b07728..ab88728a29 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -35,7 +35,6 @@ const otherFeatures = /** @type {const} */ ([ export const platformSupport = { apple: ['webCompat', 'duckPlayerNative', ...baseFeatures], 'apple-isolated': [ - 'duckPlayer', 'duckPlayerNative', 'brokerProtection', 'performanceMetrics', diff --git a/injected/src/features/duck-player-native.js b/injected/src/features/duck-player-native.js index 753d362b2d..92ffbf7f76 100644 --- a/injected/src/features/duck-player-native.js +++ b/injected/src/features/duck-player-native.js @@ -8,18 +8,18 @@ import { initDuckPlayerNative } from './duckplayer-native/duckplayer-native.js'; * @property {string} version - TODO: this is only here to test config. Replace with actual settings. */ -export class DuckPlayerNative extends ContentFeature { - init() { - if (this.platform.name !== 'ios') return; +export class DuckPlayerNativeFeature extends ContentFeature { + init(args) { + console.log('LOADING DUCK PLAYER NATIVE SCRIPTS', args); /** * This feature never operates in a frame */ - if (isBeingFramed()) return; + // if (isBeingFramed()) return; const comms = new DuckPlayerNativeMessages(this.messaging); initDuckPlayerNative(comms); } } -export default DuckPlayerNative; +export default DuckPlayerNativeFeature; From 524734cf537799fd1ad8ceb6b6458120d32d03d5 Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Tue, 8 Apr 2025 15:23:48 +0100 Subject: [PATCH 07/79] Apple Isolated only --- injected/src/features.js | 2 +- injected/src/features/duckplayer-native/native-messages.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/injected/src/features.js b/injected/src/features.js index ab88728a29..733e6df693 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -33,7 +33,7 @@ const otherFeatures = /** @type {const} */ ([ /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ /** @type {Record} */ export const platformSupport = { - apple: ['webCompat', 'duckPlayerNative', ...baseFeatures], + apple: ['webCompat', ...baseFeatures], 'apple-isolated': [ 'duckPlayerNative', 'brokerProtection', diff --git a/injected/src/features/duckplayer-native/native-messages.js b/injected/src/features/duckplayer-native/native-messages.js index 5ebb9f29e3..f5f6487041 100644 --- a/injected/src/features/duckplayer-native/native-messages.js +++ b/injected/src/features/duckplayer-native/native-messages.js @@ -27,7 +27,7 @@ export class DuckPlayerNativeMessages { // TODO: Replace with class if needed this.environment = { isIntegrationMode: function () { - return true; + return false; }, }; } From 7c2d13215c306d08c10cacf30c70281882876f97 Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Tue, 8 Apr 2025 15:29:26 +0100 Subject: [PATCH 08/79] Locale in setup --- injected/src/features/duck-player-native.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/injected/src/features/duck-player-native.js b/injected/src/features/duck-player-native.js index 92ffbf7f76..779ba4f19c 100644 --- a/injected/src/features/duck-player-native.js +++ b/injected/src/features/duck-player-native.js @@ -5,7 +5,7 @@ import { initDuckPlayerNative } from './duckplayer-native/duckplayer-native.js'; /** * @typedef InitialSettings - The initial payload used to communicate render-blocking information - * @property {string} version - TODO: this is only here to test config. Replace with actual settings. + * @property {string} locale - UI locale */ export class DuckPlayerNativeFeature extends ContentFeature { From 608fc505d2d3812849a45acc820c64d65bd90af8 Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Tue, 8 Apr 2025 15:30:12 +0100 Subject: [PATCH 09/79] Doc fix --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3d3c19fcd6..8b4b35b403 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test-int-snapshots": "npm run test-int-snapshots --workspaces --if-present", "test-int-snapshots-update": "npm run test-int-snapshots-update --workspaces --if-present", "test-clean-tree": "npm run build && sh scripts/check-for-changes.sh", - "docs": "typedoc", + "docs": "npm cache clean --force && npm ci && typedoc", "docs-watch": "typedoc --watch", "tsc": "tsc", "tsc-watch": "tsc --watch", From de3c876621686373a7ddfb2dec6f1c6174ea554f Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Tue, 8 Apr 2025 19:09:30 +0100 Subject: [PATCH 10/79] Logging timestamp --- injected/src/features/duckplayer-native/duckplayer-native.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/injected/src/features/duckplayer-native/duckplayer-native.js b/injected/src/features/duckplayer-native/duckplayer-native.js index 90ed097ced..7b55e22b54 100644 --- a/injected/src/features/duckplayer-native/duckplayer-native.js +++ b/injected/src/features/duckplayer-native/duckplayer-native.js @@ -54,6 +54,8 @@ export async function initDuckPlayerNative(messages) { /* Start timestamp polling */ const timestampPolling = setInterval(() => { + const timestamp = getCurrentTimestamp(); + console.log('Sending timestamp', timestamp); messages.onGetCurrentTimestamp(getCurrentTimestamp()); }, 300); From 43b3abf63c9bebc0afcd8c3429074c21ee056bcd Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Tue, 8 Apr 2025 19:12:32 +0100 Subject: [PATCH 11/79] onCurrentTimestamp --- injected/src/features.js | 9 +-------- injected/src/features/duck-player-native.js | 2 +- .../src/features/duckplayer-native/duckplayer-native.js | 4 ++-- .../src/features/duckplayer-native/native-messages.js | 6 +++--- 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/injected/src/features.js b/injected/src/features.js index 733e6df693..02c5bd1b48 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -34,14 +34,7 @@ const otherFeatures = /** @type {const} */ ([ /** @type {Record} */ export const platformSupport = { apple: ['webCompat', ...baseFeatures], - 'apple-isolated': [ - 'duckPlayerNative', - 'brokerProtection', - 'performanceMetrics', - 'clickToLoad', - 'messageBridge', - 'favicon', - ], + 'apple-isolated': ['duckPlayerNative', 'brokerProtection', 'performanceMetrics', 'clickToLoad', 'messageBridge', 'favicon'], android: [...baseFeatures, 'webCompat', 'breakageReporting', 'duckPlayer', 'messageBridge'], 'android-broker-protection': ['brokerProtection'], 'android-autofill-password-import': ['autofillPasswordImport'], diff --git a/injected/src/features/duck-player-native.js b/injected/src/features/duck-player-native.js index 779ba4f19c..53add63be2 100644 --- a/injected/src/features/duck-player-native.js +++ b/injected/src/features/duck-player-native.js @@ -1,5 +1,5 @@ import ContentFeature from '../content-feature.js'; -import { isBeingFramed } from '../utils.js'; +// import { isBeingFramed } from '../utils.js'; import { DuckPlayerNativeMessages } from './duckplayer-native/native-messages.js'; import { initDuckPlayerNative } from './duckplayer-native/duckplayer-native.js'; diff --git a/injected/src/features/duckplayer-native/duckplayer-native.js b/injected/src/features/duckplayer-native/duckplayer-native.js index 7b55e22b54..e032d3068d 100644 --- a/injected/src/features/duckplayer-native/duckplayer-native.js +++ b/injected/src/features/duckplayer-native/duckplayer-native.js @@ -27,7 +27,7 @@ export async function initDuckPlayerNative(messages) { /** * Set up subscription listeners */ - // messages.onGetCurrentTimestamp(() => { + // messages.onCurrentTimestamp(() => { // console.log('GET CURRENT TIMESTAMP'); // getCurrentTimestamp(); // }); @@ -56,7 +56,7 @@ export async function initDuckPlayerNative(messages) { const timestampPolling = setInterval(() => { const timestamp = getCurrentTimestamp(); console.log('Sending timestamp', timestamp); - messages.onGetCurrentTimestamp(getCurrentTimestamp()); + messages.onCurrentTimestamp(getCurrentTimestamp()); }, 300); sideEffects.push(() => { diff --git a/injected/src/features/duckplayer-native/native-messages.js b/injected/src/features/duckplayer-native/native-messages.js index f5f6487041..be32b19f8a 100644 --- a/injected/src/features/duckplayer-native/native-messages.js +++ b/injected/src/features/duckplayer-native/native-messages.js @@ -37,7 +37,7 @@ export class DuckPlayerNativeMessages { */ initialSetup() { if (this.environment.isIntegrationMode()) { - return Promise.resolve({ version: '1' }); + return Promise.resolve({ locale: 'en' }); } return this.messaging.request(constants.MSG_NAME_INITIAL_SETUP); } @@ -46,8 +46,8 @@ export class DuckPlayerNativeMessages { * Notifies with current timestamp * @param {number} timestamp */ - onGetCurrentTimestamp(timestamp) { - return this.messaging.notify('onGetCurrentTimestamp', { timestamp }); + onCurrentTimestamp(timestamp) { + return this.messaging.notify('onCurrentTimestamp', { timestamp }); } /** From e14db904cae1393972c6c92c0f929b63933fbc12 Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Tue, 8 Apr 2025 20:31:05 +0100 Subject: [PATCH 12/79] Unlogging timestamp --- injected/src/features/duckplayer-native/duckplayer-native.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/injected/src/features/duckplayer-native/duckplayer-native.js b/injected/src/features/duckplayer-native/duckplayer-native.js index e032d3068d..de64c29c7b 100644 --- a/injected/src/features/duckplayer-native/duckplayer-native.js +++ b/injected/src/features/duckplayer-native/duckplayer-native.js @@ -55,8 +55,7 @@ export async function initDuckPlayerNative(messages) { /* Start timestamp polling */ const timestampPolling = setInterval(() => { const timestamp = getCurrentTimestamp(); - console.log('Sending timestamp', timestamp); - messages.onCurrentTimestamp(getCurrentTimestamp()); + messages.onCurrentTimestamp(timestamp); }, 300); sideEffects.push(() => { From 05b042e1e26f9dd1ecca5f8bdc60fea308c0474a Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Tue, 8 Apr 2025 20:48:24 +0100 Subject: [PATCH 13/79] Removed duplicate observe call --- injected/src/features/duckplayer-native/error-detection.js | 1 - 1 file changed, 1 deletion(-) diff --git a/injected/src/features/duckplayer-native/error-detection.js b/injected/src/features/duckplayer-native/error-detection.js index 1f194aeeef..4a7d3cec9e 100644 --- a/injected/src/features/duckplayer-native/error-detection.js +++ b/injected/src/features/duckplayer-native/error-detection.js @@ -21,7 +21,6 @@ export class ErrorDetection { // TODO: Get settings from native signInRequiredSelector: '[href*="//support.google.com/youtube/answer/3037019"]', }; - this.observe(); } observe() { From a7ea5af41594be822b7654b8c2ee63c8b78286a2 Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Tue, 8 Apr 2025 21:22:30 +0100 Subject: [PATCH 14/79] More logging --- .../src/features/duckplayer-native/duckplayer-native.js | 6 +++--- injected/src/features/duckplayer-native/native-messages.js | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/injected/src/features/duckplayer-native/duckplayer-native.js b/injected/src/features/duckplayer-native/duckplayer-native.js index de64c29c7b..f73af4b316 100644 --- a/injected/src/features/duckplayer-native/duckplayer-native.js +++ b/injected/src/features/duckplayer-native/duckplayer-native.js @@ -62,7 +62,7 @@ export async function initDuckPlayerNative(messages) { clearInterval(timestampPolling); }); - return async () => { - return await Promise.all(sideEffects.map((destroy) => destroy())); - }; + // return async () => { + // return await Promise.all(sideEffects.map((destroy) => destroy())); + // }; } diff --git a/injected/src/features/duckplayer-native/native-messages.js b/injected/src/features/duckplayer-native/native-messages.js index be32b19f8a..cc063e543a 100644 --- a/injected/src/features/duckplayer-native/native-messages.js +++ b/injected/src/features/duckplayer-native/native-messages.js @@ -55,6 +55,7 @@ export class DuckPlayerNativeMessages { * @param {() => void} callback */ onMediaControl(callback) { + console.log('Subscribing to onMediaControl'); return this.messaging.subscribe('onMediaControl', callback); } @@ -63,6 +64,7 @@ export class DuckPlayerNativeMessages { * @param {(muteSettings: muteSettings) => void} callback */ onMuteAudio(callback) { + console.log('Subscribing to onMuteAudio'); return this.messaging.subscribe('onMuteAudio', callback); } @@ -71,6 +73,7 @@ export class DuckPlayerNativeMessages { * @param {() => void} callback */ onSerpNotify(callback) { + console.log('Subscribing to onSerpNotify'); return this.messaging.subscribe('onSerpNotify', callback); } From 7217d8f77424d372c21da5679e9ebd6324bc5fc1 Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Wed, 9 Apr 2025 16:16:36 +0100 Subject: [PATCH 15/79] Media control params and other tweaks --- injected/src/features/duck-player-native.js | 1 + .../duckplayer-native/duckplayer-native.js | 6 +- .../duckplayer-native/error-detection.js | 21 +- .../duckplayer-native/media-control.js | 233 +++++++++--------- .../duckplayer-native/native-messages.js | 9 +- 5 files changed, 146 insertions(+), 124 deletions(-) diff --git a/injected/src/features/duck-player-native.js b/injected/src/features/duck-player-native.js index 53add63be2..aa86fd5010 100644 --- a/injected/src/features/duck-player-native.js +++ b/injected/src/features/duck-player-native.js @@ -11,6 +11,7 @@ import { initDuckPlayerNative } from './duckplayer-native/duckplayer-native.js'; export class DuckPlayerNativeFeature extends ContentFeature { init(args) { console.log('LOADING DUCK PLAYER NATIVE SCRIPTS', args); + console.log('Duck Player Native Feature', args?.bundledConfig?.features?.duckPlayerNative); /** * This feature never operates in a frame diff --git a/injected/src/features/duckplayer-native/duckplayer-native.js b/injected/src/features/duckplayer-native/duckplayer-native.js index f73af4b316..251e20202c 100644 --- a/injected/src/features/duckplayer-native/duckplayer-native.js +++ b/injected/src/features/duckplayer-native/duckplayer-native.js @@ -32,9 +32,9 @@ export async function initDuckPlayerNative(messages) { // getCurrentTimestamp(); // }); - messages.onMediaControl(() => { - console.log('MEDIA CONTROL'); - mediaControl(); + messages.onMediaControl(({ pause }) => { + console.log('MEDIA CONTROL', pause); + mediaControl(pause); }); messages.onMuteAudio(({ mute }) => { diff --git a/injected/src/features/duckplayer-native/error-detection.js b/injected/src/features/duckplayer-native/error-detection.js index 4a7d3cec9e..e6ca5c2d20 100644 --- a/injected/src/features/duckplayer-native/error-detection.js +++ b/injected/src/features/duckplayer-native/error-detection.js @@ -62,8 +62,10 @@ export class ErrorDetection { mutation.addedNodes.forEach((node) => { if (this.checkForError(node)) { console.log('A node with an error has been added to the document:', node); - const error = this.getErrorType(); - this.messages.onYoutubeError(error); + setTimeout(() => { + const error = this.getErrorType(); + this.messages.onYoutubeError(error); + }, 4000); } }); } @@ -78,8 +80,17 @@ export class ErrorDetection { const currentWindow = /** @type {Window & typeof globalThis & { ytcfg: object }} */ (window); let playerResponse; + while (!currentWindow.ytcfg) { + console.log('Waiting for ytcfg'); + } + + console.log('Got ytcfg', currentWindow.ytcfg); + try { - playerResponse = JSON.parse(currentWindow.ytcfg?.get('PLAYER_VARS')?.embedded_player_response); + const playerResponseJSON = currentWindow.ytcfg?.get('PLAYER_VARS')?.embedded_player_response; + console.log("Player response", playerResponseJSON); + + playerResponse = JSON.parse(playerResponseJSON); } catch (e) { console.log('Could not parse player response', e); } @@ -93,16 +104,19 @@ export class ErrorDetection { if (status === 'UNPLAYABLE') { // 1.1. Check for presence of desktopLegacyAgeGateReason if (desktopLegacyAgeGateReason === 1) { + console.log('AGE RESTRICTED ERROR'); return YOUTUBE_ERRORS.ageRestricted; } // 1.2. Fall back to embed not allowed error + console.log('NO EMBED ERROR'); return YOUTUBE_ERRORS.noEmbed; } // 2. Check for sign-in support link try { if (this.settings?.signInRequiredSelector && !!document.querySelector(this.settings.signInRequiredSelector)) { + console.log('SIGN-IN ERROR'); return YOUTUBE_ERRORS.signInRequired; } } catch (e) { @@ -111,6 +125,7 @@ export class ErrorDetection { } // 3. Fall back to unknown error + console.log('UNKNOWN ERROR'); return YOUTUBE_ERRORS.unknown; } diff --git a/injected/src/features/duckplayer-native/media-control.js b/injected/src/features/duckplayer-native/media-control.js index 39c319a3d1..894a9b5c5e 100644 --- a/injected/src/features/duckplayer-native/media-control.js +++ b/injected/src/features/duckplayer-native/media-control.js @@ -1,9 +1,14 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck - Typing will be fixed in the future -export function mediaControl() { +/** + * + * @param {boolean} pause + */ +export function mediaControl(pause) { // Initialize state if not exists if (!window._mediaControlState) { + console.log('Creating control state'); window._mediaControlState = { observer: null, userInitiated: false, @@ -16,132 +21,128 @@ export function mediaControl() { // Block playback handler const blockPlayback = function (event) { + console.log('Blocking playback handler'); event.preventDefault(); event.stopPropagation(); return false; }; - // The actual media control function - function mediaControl(pause) { - state.isPaused = pause; - - if (pause) { - // Capture play events at the earliest possible moment - document.addEventListener('play', blockPlayback, true); - document.addEventListener('playing', blockPlayback, true); - - // Block HTML5 video/audio playback methods - HTMLMediaElement.prototype.play = function () { - this.pause(); - return Promise.reject(new Error('Playback blocked')); - }; - - // Override load to ensure media starts paused - HTMLMediaElement.prototype.load = function () { - this.autoplay = false; - this.pause(); - return state.originalLoad.apply(this, arguments); - }; - - // Listen for user interactions that may lead to playback - document.addEventListener( - 'touchstart', - () => { - state.userInitiated = true; - - // Remove the early blocking listeners - document.removeEventListener('play', blockPlayback, true); - document.removeEventListener('playing', blockPlayback, true); - - // Reset HTMLMediaElement.prototype.play - HTMLMediaElement.prototype.play = state.originalPlay; - - // Unmute all media elements when user interacts - document.querySelectorAll('audio, video').forEach((media) => { - media.muted = false; - }); - - // Reset after a short delay - setTimeout(() => { - state.userInitiated = false; - - // Re-add blocking if still in paused state - if (state.isPaused) { - document.addEventListener('play', blockPlayback, true); - document.addEventListener('playing', blockPlayback, true); - HTMLMediaElement.prototype.play = function () { - this.pause(); - return Promise.reject(new Error('Playback blocked')); - }; - } - }, 500); - }, - true, - ); - - // Initial pause of all media - document.querySelectorAll('audio, video').forEach((media) => { - media.pause(); - media.muted = true; - media.autoplay = false; - }); + console.log('Running media control with pause: ', pause); + state.isPaused = pause; + + if (pause) { + // Capture play events at the earliest possible moment + document.addEventListener('play', blockPlayback, true); + document.addEventListener('playing', blockPlayback, true); + + // Block HTML5 video/audio playback methods + HTMLMediaElement.prototype.play = function () { + this.pause(); + return Promise.reject(new Error('Playback blocked')); + }; + + // Override load to ensure media starts paused + HTMLMediaElement.prototype.load = function () { + this.autoplay = false; + this.pause(); + return state.originalLoad.apply(this, arguments); + }; + + // Listen for user interactions that may lead to playback + document.addEventListener( + 'touchstart', + () => { + state.userInitiated = true; + + // Remove the early blocking listeners + document.removeEventListener('play', blockPlayback, true); + document.removeEventListener('playing', blockPlayback, true); + + // Reset HTMLMediaElement.prototype.play + HTMLMediaElement.prototype.play = state.originalPlay; - // Monitor DOM for newly added media elements - if (state.observer) { - state.observer.disconnect(); - } + // Unmute all media elements when user interacts + document.querySelectorAll('audio, video').forEach((media) => { + media.muted = false; + }); + + // Reset after a short delay + setTimeout(() => { + state.userInitiated = false; + + // Re-add blocking if still in paused state + if (state.isPaused) { + document.addEventListener('play', blockPlayback, true); + document.addEventListener('playing', blockPlayback, true); + HTMLMediaElement.prototype.play = function () { + this.pause(); + return Promise.reject(new Error('Playback blocked')); + }; + } + }, 500); + }, + true, + ); + + // Initial pause of all media + document.querySelectorAll('audio, video').forEach((media) => { + media.pause(); + media.muted = true; + media.autoplay = false; + }); + + // Monitor DOM for newly added media elements + if (state.observer) { + state.observer.disconnect(); + } - state.observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - // Check for added nodes - mutation.addedNodes.forEach((node) => { - if (node.tagName === 'VIDEO' || node.tagName === 'AUDIO') { + state.observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + // Check for added nodes + mutation.addedNodes.forEach((node) => { + if (node.tagName === 'VIDEO' || node.tagName === 'AUDIO') { + if (!state.userInitiated) { + node.pause(); + node.muted = true; + node.autoplay = false; + } + } else if (node.querySelectorAll) { + node.querySelectorAll('audio, video').forEach((media) => { if (!state.userInitiated) { - node.pause(); - node.muted = true; - node.autoplay = false; + media.pause(); + media.muted = true; + media.autoplay = false; } - } else if (node.querySelectorAll) { - node.querySelectorAll('audio, video').forEach((media) => { - if (!state.userInitiated) { - media.pause(); - media.muted = true; - media.autoplay = false; - } - }); - } - }); + }); + } }); }); - - state.observer.observe(document.documentElement || document.body, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['autoplay', 'src', 'playing'], - }); - } else { - // Restore original methods - HTMLMediaElement.prototype.play = state.originalPlay; - HTMLMediaElement.prototype.load = state.originalLoad; - - // Remove listeners - document.removeEventListener('play', blockPlayback, true); - document.removeEventListener('playing', blockPlayback, true); - - // Clean up observer - if (state.observer) { - state.observer.disconnect(); - state.observer = null; - } - - // Unmute all media - document.querySelectorAll('audio, video').forEach((media) => { - media.muted = false; - }); + }); + + state.observer.observe(document.documentElement || document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['autoplay', 'src', 'playing'], + }); + } else { + // Restore original methods + HTMLMediaElement.prototype.play = state.originalPlay; + HTMLMediaElement.prototype.load = state.originalLoad; + + // Remove listeners + document.removeEventListener('play', blockPlayback, true); + document.removeEventListener('playing', blockPlayback, true); + + // Clean up observer + if (state.observer) { + state.observer.disconnect(); + state.observer = null; } - } - // Export function - window.mediaControl = mediaControl; + // Unmute all media + document.querySelectorAll('audio, video').forEach((media) => { + media.muted = false; + }); + } } diff --git a/injected/src/features/duckplayer-native/native-messages.js b/injected/src/features/duckplayer-native/native-messages.js index cc063e543a..69df64ba80 100644 --- a/injected/src/features/duckplayer-native/native-messages.js +++ b/injected/src/features/duckplayer-native/native-messages.js @@ -1,10 +1,15 @@ import * as constants from './constants.js'; /** - * @typedef {object} muteSettings - Settings passing to the onMute callback + * @typedef {object} muteSettings - Settings passed to the onMute callback * @property {boolean} mute - Set to true to mute the video, false to unmute */ +/** + * @typedef {object} mediaControlSettings - Settings passed to the onMediaControll callback + * @property {boolean} pause - Set to true to pause the video, false to play + */ + /** * @typedef {import("@duckduckgo/messaging").Messaging} Messaging * @@ -52,7 +57,7 @@ export class DuckPlayerNativeMessages { /** * Subscribe to media control events - * @param {() => void} callback + * @param {(mediaControlSettings: mediaControlSettings) => void} callback */ onMediaControl(callback) { console.log('Subscribing to onMediaControl'); From 9c1604502266f0445c9c1cf59ac6bfca8789a054 Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Tue, 15 Apr 2025 14:19:39 +0100 Subject: [PATCH 16/79] Thumbnail overlay --- .../test-pages/duckplayer/config/native.json | 8 ++ .../duckplayer/pages/player-native.html | 14 +-- injected/src/features.js | 2 +- .../duckplayer-native/duckplayer-native.js | 21 +++- .../overlays/thumbnail-overlay.css | 96 +++++++++++++++++++ .../overlays/thumbnail-overlay.js | 64 +++++++++++++ 6 files changed, 189 insertions(+), 16 deletions(-) create mode 100644 injected/integration-test/test-pages/duckplayer/config/native.json create mode 100644 injected/src/features/duckplayer-native/overlays/thumbnail-overlay.css create mode 100644 injected/src/features/duckplayer-native/overlays/thumbnail-overlay.js diff --git a/injected/integration-test/test-pages/duckplayer/config/native.json b/injected/integration-test/test-pages/duckplayer/config/native.json new file mode 100644 index 0000000000..311bb207fe --- /dev/null +++ b/injected/integration-test/test-pages/duckplayer/config/native.json @@ -0,0 +1,8 @@ +{ + "unprotectedTemporary": [], + "features": { + "duckPlayerNative": { + "state": "enabled" + } + } +} diff --git a/injected/integration-test/test-pages/duckplayer/pages/player-native.html b/injected/integration-test/test-pages/duckplayer/pages/player-native.html index 297614e5a4..41970e2367 100644 --- a/injected/integration-test/test-pages/duckplayer/pages/player-native.html +++ b/injected/integration-test/test-pages/duckplayer/pages/player-native.html @@ -154,22 +154,16 @@ diff --git a/injected/src/features/duck-player-native.js b/injected/src/features/duck-player-native.js index aa86fd5010..98aa2df2a0 100644 --- a/injected/src/features/duck-player-native.js +++ b/injected/src/features/duck-player-native.js @@ -1,7 +1,9 @@ import ContentFeature from '../content-feature.js'; // import { isBeingFramed } from '../utils.js'; -import { DuckPlayerNativeMessages } from './duckplayer-native/native-messages.js'; -import { initDuckPlayerNative } from './duckplayer-native/duckplayer-native.js'; +import { DuckPlayerNativeMessages } from './duckplayer-native/messages.js'; +import { mockTransport } from './duckplayer-native/mock-transport.js'; +import { DuckPlayerNative } from './duckplayer-native/duckplayer-native.js'; +import { Environment } from './duckplayer-native/environment.js'; /** * @typedef InitialSettings - The initial payload used to communicate render-blocking information @@ -10,16 +12,38 @@ import { initDuckPlayerNative } from './duckplayer-native/duckplayer-native.js'; export class DuckPlayerNativeFeature extends ContentFeature { init(args) { - console.log('LOADING DUCK PLAYER NATIVE SCRIPTS', args); - console.log('Duck Player Native Feature', args?.bundledConfig?.features?.duckPlayerNative); + console.log('[duckplayer-native] Loading', args); + // TODO: Should we keep this? /** * This feature never operates in a frame */ // if (isBeingFramed()) return; + /** + * @type {import("@duckduckgo/privacy-configuration/schema/features/duckplayer-native.js").DuckPlayerNativeSettings} + */ + // TODO: Why isn't this working? + // const settings = this.getFeatureSetting('settings'); + const settings = args?.featureSettings?.duckPlayerNative; + console.log('[duckplayer-native] Selectors', settings?.selectors); + + const locale = args?.locale || args?.language || 'en'; + const env = new Environment({ + debug: this.isDebug, + injectName: import.meta.injectName, + platform: this.platform, + locale, + }); + + if (env.isIntegrationMode()) { + // TODO: Better way than patching transport? + this.messaging.transport = mockTransport(); + } + const comms = new DuckPlayerNativeMessages(this.messaging); - initDuckPlayerNative(comms); + const duckPlayerNative = new DuckPlayerNative(settings, env, comms); + duckPlayerNative.init(); } } diff --git a/injected/src/features/duckplayer-native/constants.js b/injected/src/features/duckplayer-native/constants.js index 29e40718c7..95a47f03e8 100644 --- a/injected/src/features/duckplayer-native/constants.js +++ b/injected/src/features/duckplayer-native/constants.js @@ -1,10 +1,6 @@ export const MSG_NAME_INITIAL_SETUP = 'initialSetup'; -export const MSG_NAME_SET_VALUES = 'setUserValues'; -export const MSG_NAME_READ_VALUES = 'getUserValues'; -export const MSG_NAME_READ_VALUES_SERP = 'readUserValues'; -export const MSG_NAME_OPEN_PLAYER = 'openDuckPlayer'; -export const MSG_NAME_OPEN_INFO = 'openInfo'; -export const MSG_NAME_PUSH_DATA = 'onUserValuesChanged'; -export const MSG_NAME_PIXEL = 'sendDuckPlayerPixel'; -export const MSG_NAME_PROXY_INCOMING = 'ddg-serp-yt'; -export const MSG_NAME_PROXY_RESPONSE = 'ddg-serp-yt-response'; +export const MSG_NAME_CURRENT_TIMESTAMP = 'onCurrentTimestamp'; +export const MSG_NAME_MEDIA_CONTROL = 'onMediaControl'; +export const MSG_NAME_MUTE_AUDIO = 'onMuteAudio'; +export const MSG_NAME_SERP_NOTIFY = 'onSerpNotify'; +export const MSG_NAME_YOUTUBE_ERROR = 'onYoutubeError'; diff --git a/injected/src/features/duckplayer-native/custom-error/custom-error.css b/injected/src/features/duckplayer-native/custom-error/custom-error.css index 5621405931..a870074349 100644 --- a/injected/src/features/duckplayer-native/custom-error/custom-error.css +++ b/injected/src/features/duckplayer-native/custom-error/custom-error.css @@ -1,18 +1,23 @@ /* -- VIDEO PLAYER OVERLAY */ :host { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - z-index: 10000; --title-size: 16px; --title-line-height: 20px; --title-gap: 16px; --button-gap: 6px; + --padding: 4px; --logo-size: 32px; --logo-gap: 8px; --gutter: 16px; + --background-color: black; + --background-color-alt: #2f2f2f; + + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + z-index: 10000; + height: 100vh; } /* iphone 15 */ @media screen and (min-width: 390px) { @@ -55,17 +60,22 @@ box-sizing: border-box; } +.wrapper { + align-items: center; + background-color: var(--background-color); + display: flex; + height: 100%; + padding: var(--padding); +} + .error { align-items: center; - background: rgba(0, 0, 0, 0.6); display: grid; - height: 100%; justify-items: center; } .error.mobile { border-radius: var(--inner-radius); - height: 100%; overflow: auto; /* Prevents automatic text resizing */ @@ -77,7 +87,14 @@ } } +.error.framed { + padding: 4px; + border: 4px solid var(--background-color-alt); + border-radius: 16px; +} + .container { + background: var(--background-color); column-gap: 24px; display: flex; flex-flow: row; @@ -120,7 +137,8 @@ &::before { content: ' '; display: block; - background: url('../img/warning-96.data.svg') no-repeat; + background-image: url("data:image/svg+xml,%3Csvg fill='none' viewBox='0 0 96 96' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='red' d='M47.5 70.802c1.945 0 3.484-1.588 3.841-3.5C53.076 58.022 61.218 51 71 51h4.96c2.225 0 4.04-1.774 4.04-4 0-.026-.007-9.022-1.338-14.004a8.02 8.02 0 0 0-5.659-5.658C68.014 26 48 26 48 26s-20.015 0-25.004 1.338a8.01 8.01 0 0 0-5.658 5.658C16 37.986 16 48.401 16 48.401s0 10.416 1.338 15.405a8.01 8.01 0 0 0 5.658 5.658c4.99 1.338 24.504 1.338 24.504 1.338'/%3E%3Cpath fill='%23fff' d='m41.594 58 16.627-9.598-16.627-9.599z'/%3E%3Cpath fill='%23EB102D' d='M87 71c0 8.837-7.163 16-16 16s-16-7.163-16-16 7.163-16 16-16 16 7.163 16 16'/%3E%3Cpath fill='%23fff' d='M73 77.8a2 2 0 1 1-4 0 2 2 0 0 1 4 0m-2.039-4.4c-.706 0-1.334-.49-1.412-1.12l-.942-8.75c-.079-.7.55-1.33 1.412-1.33h1.962c.785 0 1.492.63 1.413 1.33l-.942 8.75c-.157.63-.784 1.12-1.49 1.12Z'/%3E%3Cpath fill='%23CCC' d='M92.501 59c.298 0 .595.12.823.354.454.468.454 1.23 0 1.698l-2.333 2.4a1.145 1.145 0 0 1-1.65 0 1.227 1.227 0 0 1 0-1.698l2.333-2.4c.227-.234.524-.354.822-.354zm-1.166 10.798h3.499c.641 0 1.166.54 1.166 1.2s-.525 1.2-1.166 1.2h-3.499c-.641 0-1.166-.54-1.166-1.2s.525-1.2 1.166-1.2m-1.982 8.754c.227-.234.525-.354.822-.354h.006c.297 0 .595.12.822.354l2.332 2.4c.455.467.455 1.23 0 1.697a1.145 1.145 0 0 1-1.65 0l-2.332-2.4a1.227 1.227 0 0 1 0-1.697'/%3E%3C/svg%3E%0A"); + background-repeat: no-repeat; height: 48px; width: 48px; } @@ -133,7 +151,7 @@ justify-content: start; &::before { - background-image: url('../img/warning-128.data.svg'); + background-image: url("data:image/svg+xml,%3Csvg fill='none' viewBox='0 0 128 96' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%23888' d='M16.912 31.049a1.495 1.495 0 0 1 2.114-2.114l1.932 1.932 1.932-1.932a1.495 1.495 0 0 1 2.114 2.114l-1.932 1.932 1.932 1.932a1.495 1.495 0 0 1-2.114 2.114l-1.932-1.933-1.932 1.933a1.494 1.494 0 1 1-2.114-2.114l1.932-1.932zM.582 52.91a1.495 1.495 0 0 1 2.113-2.115l1.292 1.292 1.291-1.292a1.495 1.495 0 1 1 2.114 2.114L6.1 54.2l1.292 1.292a1.495 1.495 0 1 1-2.113 2.114l-1.292-1.292-1.292 1.292a1.495 1.495 0 1 1-2.114-2.114l1.292-1.291zm104.972-15.452a1.496 1.496 0 0 1 2.114-2.114l1.291 1.292 1.292-1.292a1.495 1.495 0 0 1 2.114 2.114l-1.292 1.291 1.292 1.292a1.494 1.494 0 1 1-2.114 2.114l-1.292-1.292-1.291 1.292a1.495 1.495 0 0 1-2.114-2.114l1.292-1.292zM124.5 54c-.825 0-1.5-.675-1.5-1.5s.675-1.5 1.5-1.5 1.5.675 1.5 1.5-.675 1.5-1.5 1.5M24 67c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2' opacity='.2'/%3E%3Cpath fill='red' d='M63.5 70.802c1.945 0 3.484-1.588 3.841-3.5C69.076 58.022 77.218 51 87 51h4.96c2.225 0 4.04-1.774 4.04-4 0-.026-.007-9.022-1.338-14.004a8.02 8.02 0 0 0-5.659-5.658C84.014 26 64 26 64 26s-20.014 0-25.004 1.338a8.01 8.01 0 0 0-5.658 5.658C32 37.986 32 48.401 32 48.401s0 10.416 1.338 15.405a8.01 8.01 0 0 0 5.658 5.658c4.99 1.338 24.504 1.338 24.504 1.338'/%3E%3Cpath fill='%23fff' d='m57.594 58 16.627-9.598-16.627-9.599z'/%3E%3Cpath fill='%23EB102D' d='M103 71c0 8.837-7.163 16-16 16s-16-7.163-16-16 7.163-16 16-16 16 7.163 16 16'/%3E%3Cpath fill='%23fff' d='M89 77.8a2 2 0 1 1-4 0 2 2 0 0 1 4 0m-2.039-4.4c-.706 0-1.334-.49-1.412-1.12l-.942-8.75c-.079-.7.55-1.33 1.412-1.33h1.962c.785 0 1.492.63 1.413 1.33l-.942 8.75c-.157.63-.784 1.12-1.49 1.12Z'/%3E%3Cpath fill='%23CCC' d='M108.501 59c.298 0 .595.12.823.354.454.468.454 1.23 0 1.698l-2.333 2.4a1.145 1.145 0 0 1-1.65 0 1.226 1.226 0 0 1 0-1.698l2.332-2.4c.228-.234.525-.354.823-.354zm-1.166 10.798h3.499c.641 0 1.166.54 1.166 1.2s-.525 1.2-1.166 1.2h-3.499c-.641 0-1.166-.54-1.166-1.2s.525-1.2 1.166-1.2m-1.982 8.754c.227-.234.525-.354.822-.354h.006c.297 0 .595.12.822.354l2.333 2.4c.454.467.454 1.23 0 1.697a1.146 1.146 0 0 1-1.651 0l-2.332-2.4a1.226 1.226 0 0 1 0-1.697'/%3E%3C/svg%3E%0A"); height: 96px; width: 128px; } diff --git a/injected/src/features/duckplayer-native/custom-error/custom-error.js b/injected/src/features/duckplayer-native/custom-error/custom-error.js index a3318e6721..ee8d4c12e0 100644 --- a/injected/src/features/duckplayer-native/custom-error/custom-error.js +++ b/injected/src/features/duckplayer-native/custom-error/custom-error.js @@ -1,15 +1,9 @@ -import mobilecss from './custom-error.css'; +import css from './custom-error.css'; import { createPolicy, html } from '../../../dom-utils.js'; import { customElementsDefine, customElementsGet } from '../../../captured-globals.js'; /** @typedef {import('../error-detection').YouTubeError} YouTubeError */ -export function registerCustomElements() { - if (!customElementsGet(CustomError.CUSTOM_TAG_NAME)) { - customElementsDefine(CustomError.CUSTOM_TAG_NAME, CustomError); - } -} - /** * The custom element that we use to present our UI elements * over the YouTube player @@ -27,19 +21,38 @@ export class CustomError extends HTMLElement { /** @type {string[]} */ messages = []; + static register() { + if (!customElementsGet(CustomError.CUSTOM_TAG_NAME)) { + customElementsDefine(CustomError.CUSTOM_TAG_NAME, CustomError); + } + } + + log(message, force = false) { + if (this.testMode || force) { + console.log(`[custom-error] ${message}`); + } + } + connectedCallback() { this.createMarkupAndStyles(); } createMarkupAndStyles() { const shadow = this.attachShadow({ mode: this.testMode ? 'open' : 'closed' }); + const style = document.createElement('style'); - style.innerText = mobilecss; + style.innerText = css; + const container = document.createElement('div'); + container.classList.add('wrapper'); const content = this.render(); container.innerHTML = this.policy.createHTML(content); shadow.append(style, container); this.container = container; + + if (this.testMode) { + this.log(`Created ${CustomError.CUSTOM_TAG_NAME} with container ${container}`); + } } /** @@ -47,11 +60,12 @@ export class CustomError extends HTMLElement { */ render() { if (!this.title || !this.messages) { - console.warn('missing error text. Please assign before rendering'); + console.warn('Missing error title or messages. Please assign before rendering'); return ''; } - const messagesHtml = this.messages.map((message) => html`

${message}

`); + const { title, messages } = this; + const messagesHtml = messages.map((message) => html`

${message}

`); return html`
@@ -59,7 +73,7 @@ export class CustomError extends HTMLElement {
-

{heading}

+

${title}

${messagesHtml}
@@ -68,21 +82,36 @@ export class CustomError extends HTMLElement { } } +/** + * @param {import('../environment').Environment} environment + * @param {YouTubeError} errorId + */ +function getErrorStrings(environment, errorId) { + // TODO: get from environment strings + console.log(`Getting translations for ${errorId} from ${environment}`); + return { + title: 'YouTube won’t let Duck Player load this video', + messages: [ + 'YouTube doesn’t allow this video to be viewed outside of YouTube.', + 'You can still watch this video on YouTube, but without the added privacy of Duck Player.', + ], + }; +} + /** * * @param {HTMLElement} targetElement - * @param {object} options - * @param {string} options.title - * @param {string[]} options.messages + * @param {import('../environment').Environment} environment + * @param {YouTubeError} errorId */ -export function showError(targetElement, { title, messages }) { - registerCustomElements(); - console.log('Appending custom error view'); +export function showError(targetElement, environment, errorId) { + const { title, messages } = getErrorStrings(environment, errorId); + CustomError.register(); + const customError = /** @type {CustomError} */ (document.createElement(CustomError.CUSTOM_TAG_NAME)); - customError.testMode = true; + customError.testMode = environment.isTestMode(); customError.title = title; customError.messages = messages; - console.log('Custom error view', customError, targetElement); targetElement.appendChild(customError); return () => { diff --git a/injected/src/features/duckplayer-native/duckplayer-native.js b/injected/src/features/duckplayer-native/duckplayer-native.js index eb29709c64..adc6e3dcb4 100644 --- a/injected/src/features/duckplayer-native/duckplayer-native.js +++ b/injected/src/features/duckplayer-native/duckplayer-native.js @@ -8,96 +8,168 @@ import { stopVideoFromPlaying } from './pause-video.js'; import { showError } from './custom-error/custom-error.js'; /** - * - * @param {import('./native-messages.js').DuckPlayerNativeMessages} messages - * @returns + * @typedef {object} DuckPlayerNativeSettings + * @property {import("@duckduckgo/privacy-configuration/schema/features/duckplayer-native.js").DuckPlayerNativeSettings['selectors']} selectors */ -export async function initDuckPlayerNative(messages) { - /** @type {import("../duck-player-native.js").InitialSettings} */ - let initialSetup; + +export class DuckPlayerNative { + /** @type {DuckPlayerNativeSettings} */ + settings; + /** @type {import('./environment.js').Environment} */ + environment; + /** @type {import('./messages.js').DuckPlayerNativeMessages} */ + messages; /** @type {(() => void|null)[]} */ - const sideEffects = []; + sideEffects = []; + + /** + * @param {DuckPlayerNativeSettings} settings + * @param {import('./environment.js').Environment} environment + * @param {import('./messages.js').DuckPlayerNativeMessages} messages + */ + constructor(settings, environment, messages) { + if (!settings || !environment || !messages) { + throw new Error('Missing arguments'); + } - try { - initialSetup = await messages.initialSetup(); - } catch (e) { - console.error(e); - return; + this.settings = settings; + this.environment = environment; + this.messages = messages; } - console.log('INITIAL SETUP', initialSetup); + // TODO: Is there a class or module that does this already? + log(message, force = false) { + if (this.environment.isTestMode() || force) { + console.log(`[duckplayer-native] ${message}`); + } + } + + async init() { + /** @type {import("../duck-player-native.js").InitialSettings} */ + let initialSetup; + + // TODO: This seems to get initted twice. Check with Daniel + try { + initialSetup = await this.messages.initialSetup(); + } catch (e) { + console.error(e); + return; + } + + console.log('INITIAL SETUP', initialSetup); + + this.setupMessaging(); + this.setupErrorDetection(); + this.setupTimestampPolling(); + + // TODO: Question - when/how does the native side call the teardown handler? + return async () => { + return await Promise.all(this.sideEffects.map((destroy) => destroy())); + }; + } /** - * Set up subscription listeners + * Set up messaging event listeners */ - // messages.onCurrentTimestamp(() => { - // console.log('GET CURRENT TIMESTAMP'); - // getCurrentTimestamp(); - // }); - - const onMediaControlHandler = ({ pause }) => { - console.log('MEDIA CONTROL', pause); - - // TODO: move to settings.selectors.videoElement/videoElementContainer or something similar - const videoElementContainer = document.querySelector('#player .html5-video-player'); - const videoSelector = '#player video'; - if (videoElementContainer) { - sideEffects.push( - stopVideoFromPlaying(videoSelector), - appendThumbnailOverlay(/** @type {HTMLElement} */ (videoElementContainer)), - ); + setupMessaging() { + this.messages.onMediaControl(this.mediaControlHandler.bind(this)); + this.messages.onMuteAudio(this.muteAudioHandler.bind(this)); + this.messages.onSerpNotify(this.serpNotifyHandler.bind(this)); + // this.messages.onCurrentTimestamp(this.currentTimestampHandler.bind(this)); + } + + setupErrorDetection() { + this.log('Setting up error detection'); + const errorContainer = this.settings.selectors?.errorContainer; + const signInRequiredError = this.settings.selectors?.signInRequiredError; + if (!errorContainer || !signInRequiredError) { + console.warn('Missing error selectors in configuration'); + return; } - // mediaControl(pause); - }; + /** @type {(errorId: import('./error-detection.js').YouTubeError) => void} */ + const errorHandler = (errorId) => { + this.log(`Received error ${errorId}`); + + // Notify the browser of the error + this.messages.onYoutubeError(errorId); + + const targetElement = document.querySelector(errorContainer); + if (targetElement) { + showError(/** @type {HTMLElement} */ (targetElement), this.environment, errorId); + } + }; + + /** @type {import('./error-detection.js').ErrorDetectionSettings} */ + const errorDetectionSettings = { + signInRequiredSelector: signInRequiredError, + testMode: this.environment.isTestMode(), + callback: errorHandler, + }; + + const errorDetection = new ErrorDetection(errorDetectionSettings); + const destroy = errorDetection.observe(); + if (destroy) { + this.sideEffects.push(destroy); + } + } - messages.onMediaControl(onMediaControlHandler); + /** + * Sends the timestamp to the browser every 300ms + * TODO: Can we not brute force this? + */ + setupTimestampPolling() { + const timestampPolling = setInterval(() => { + const timestamp = getCurrentTimestamp(); + this.messages.onCurrentTimestamp(timestamp); + }, 300); + + this.sideEffects.push(() => { + clearInterval(timestampPolling); + }); + } - messages.onMuteAudio(({ mute }) => { - console.log('MUTE AUDIO', mute); - muteAudio(mute); - }); + /** + * + * @param {import('./messages.js').mediaControlSettings} settings + */ + mediaControlHandler({ pause }) { + this.log(`Running media control handler. Pause: ${pause}`); + + const videoElement = this.settings.selectors?.videoElement; + const videoElementContainer = this.settings.selectors?.videoElementContainer; + if (!videoElementContainer || !videoElement) { + console.warn('Missing media control selectors in config'); + return; + } - messages.onSerpNotify(() => { - console.log('SERP PROXY'); - serpNotify(); - }); - - /* Set up error handler */ - /** @type {(errorId: import('./error-detection.js').YouTubeError) => void} */ - const errorHandler = (errorId) => { - console.log('Got error', errorId); - // TODO: move to settings.selectors.errorContainer or something similar - const errorContainer = document.querySelector('body'); - if (errorContainer) { - // TODO: Get error messages from translated strings - showError(/** @type {HTMLElement} */ (errorContainer), { - title: 'Test Error', - messages: ['This is an error'], - }); + const targetElement = document.querySelector(videoElementContainer); + if (targetElement) { + this.sideEffects.push( + stopVideoFromPlaying(videoElement), + appendThumbnailOverlay(/** @type {HTMLElement} */ (targetElement), this.environment), + ); } - }; - /* Start error detection */ - const errorDetection = new ErrorDetection(messages, errorHandler); - const destroy = errorDetection.observe(); - if (destroy) { - sideEffects.push(destroy); + // mediaControl(pause); } - /* Start timestamp polling */ - const timestampPolling = setInterval(() => { - const timestamp = getCurrentTimestamp(); - messages.onCurrentTimestamp(timestamp); - }, 300); - - sideEffects.push(() => { - clearInterval(timestampPolling); - }); + /** + * + * @param {import('./messages.js').muteSettings} settings + */ + muteAudioHandler({ mute }) { + this.log(`Running mute audio handler. Mute: ${mute}`); + muteAudio(mute); + } - onMediaControlHandler({ pause: false }); + serpNotifyHandler() { + this.log('Running SERP notify handler'); + serpNotify(); + } - // return async () => { - // return await Promise.all(sideEffects.map((destroy) => destroy())); - // }; + currentTimestampHandler() { + this.log('Running current timestamp handler'); + getCurrentTimestamp(); + } } diff --git a/injected/src/features/duckplayer-native/environment.js b/injected/src/features/duckplayer-native/environment.js new file mode 100644 index 0000000000..cf9f0a0d0b --- /dev/null +++ b/injected/src/features/duckplayer-native/environment.js @@ -0,0 +1,85 @@ +// TODO: Update locales +import strings from '../../../../build/locales/duckplayer-locales.js'; + +export class Environment { + allowedProxyOrigins = ['duckduckgo.com']; + _strings = JSON.parse(strings); + + /** + * @param {object} params + * @param {{name: string}} params.platform + * @param {boolean|null|undefined} [params.debug] + * @param {ImportMeta['injectName']} params.injectName + * @param {string} params.locale + */ + constructor(params) { + this.debug = Boolean(params.debug); + this.injectName = params.injectName; + this.platform = params.platform; + this.locale = params.locale; + } + + get strings() { + const matched = this._strings[this.locale]; + if (matched) return matched['native.json']; + return this._strings.en['native.json']; + } + + /** + * This is the URL of the page that the user is currently on + * It's abstracted so that we can mock it in tests + * @return {string} + */ + getPlayerPageHref() { + if (this.debug) { + const url = new URL(window.location.href); + if (url.hostname === 'www.youtube.com') return window.location.href; + + // reflect certain query params, this is useful for testing + if (url.searchParams.has('v')) { + const base = new URL('/watch', 'https://youtube.com'); + base.searchParams.set('v', url.searchParams.get('v') || ''); + return base.toString(); + } + + return 'https://youtube.com/watch?v=123'; + } + return window.location.href; + } + + setHref(href) { + window.location.href = href; + } + + isIntegrationMode() { + return this.debug === true && this.injectName === 'integration'; + } + + isTestMode() { + return this.debug === true; + } + + /** + * @return {boolean} + */ + get isMobile() { + return this.platform.name === 'ios' || this.platform.name === 'android'; + } + + /** + * @return {boolean} + */ + get isDesktop() { + return !this.isMobile; + } + + /** + * @return {'desktop' | 'mobile'} + */ + get layout() { + if (this.platform.name === 'ios' || this.platform.name === 'android') { + return 'mobile'; + } + return 'desktop'; + } +} diff --git a/injected/src/features/duckplayer-native/error-detection.js b/injected/src/features/duckplayer-native/error-detection.js index d96f317f6d..0c0d4eb8a9 100644 --- a/injected/src/features/duckplayer-native/error-detection.js +++ b/injected/src/features/duckplayer-native/error-detection.js @@ -1,5 +1,14 @@ /** @typedef {"age-restricted" | "sign-in-required" | "no-embed" | "unknown"} YouTubeError */ +/** @typedef {(error: YouTubeError) => void} ErrorDetectionCallback */ + +/** + * @typedef {object} ErrorDetectionSettings + * @property {string} signInRequiredSelector + * @property {ErrorDetectionCallback} callback + * @property {boolean} testMode + */ + /** @type {Record} */ export const YOUTUBE_ERRORS = { ageRestricted: 'age-restricted', @@ -12,18 +21,26 @@ export const YOUTUBE_ERRORS = { * Detects YouTube errors based on DOM queries */ export class ErrorDetection { - /** @type {import('./native-messages.js').DuckPlayerNativeMessages} */ - messages; - /** @type {((error: YouTubeError) => void)} */ - errorCallback; - - constructor(messages, errorCallback) { - this.messages = messages; - this.errorCallback = errorCallback; - this.settings = { - // TODO: Get settings from native - signInRequiredSelector: '[href*="//support.google.com/youtube/answer/3037019"]', - }; + /** @type {string} */ + signInRequiredSelector; + /** @type {ErrorDetectionCallback} */ + callback; + /** @type {boolean} */ + testMode; + + /** + * @param {ErrorDetectionSettings} settings + */ + constructor({ signInRequiredSelector, callback, testMode }) { + this.signInRequiredSelector = signInRequiredSelector; + this.callback = callback; + this.testMode = testMode; + } + + log(message, force = false) { + if (this.testMode || force) { + console.log(`[error-detection] ${message}`); + } } /** @@ -31,7 +48,6 @@ export class ErrorDetection { * @returns {(() => void)|void} */ observe() { - console.log('Setting up error detection...'); const documentBody = document?.body; if (documentBody) { // Check if iframe already contains error @@ -58,13 +74,15 @@ export class ErrorDetection { /** * - * @param {YouTubeError} error + * @param {YouTubeError} errorId */ - handleError(error) { - if (this.errorCallback) { - this.errorCallback(error); + handleError(errorId) { + if (this.callback) { + this.log(`Calling error handler for ${errorId}`); + this.callback(errorId); + } else { + console.warn('No error callback found'); } - this.messages.onYoutubeError(error); } /** @@ -78,10 +96,8 @@ export class ErrorDetection { mutation.addedNodes.forEach((node) => { if (this.checkForError(node)) { console.log('A node with an error has been added to the document:', node); - setTimeout(() => { - const error = this.getErrorType(); - this.handleError(error); - }, 4000); + const error = this.getErrorType(); + this.handleError(error); } }); } @@ -96,8 +112,8 @@ export class ErrorDetection { const currentWindow = /** @type {Window & typeof globalThis & { ytcfg: object }} */ (window); let playerResponse; - while (!currentWindow.ytcfg) { - console.log('Waiting for ytcfg'); + if (!currentWindow.ytcfg) { + console.log('ytcfg missing!'); } console.log('Got ytcfg', currentWindow.ytcfg); @@ -131,7 +147,7 @@ export class ErrorDetection { // 2. Check for sign-in support link try { - if (this.settings?.signInRequiredSelector && !!document.querySelector(this.settings.signInRequiredSelector)) { + if (this.signInRequiredSelector && !!document.querySelector(this.signInRequiredSelector)) { console.log('SIGN-IN ERROR'); return YOUTUBE_ERRORS.signInRequired; } @@ -155,7 +171,6 @@ export class ErrorDetection { const element = /** @type {HTMLElement} */ (node); // Check if element has the error class or contains any children with that class const isError = element.classList.contains('ytp-error') || !!element.querySelector('.ytp-error'); - console.log('Is error?', isError); return isError; } diff --git a/injected/src/features/duckplayer-native/native-messages.js b/injected/src/features/duckplayer-native/messages.js similarity index 75% rename from injected/src/features/duckplayer-native/native-messages.js rename to injected/src/features/duckplayer-native/messages.js index 69df64ba80..df7a370e68 100644 --- a/injected/src/features/duckplayer-native/native-messages.js +++ b/injected/src/features/duckplayer-native/messages.js @@ -32,7 +32,7 @@ export class DuckPlayerNativeMessages { // TODO: Replace with class if needed this.environment = { isIntegrationMode: function () { - return false; + return true; }, }; } @@ -41,9 +41,6 @@ export class DuckPlayerNativeMessages { * @returns {Promise} */ initialSetup() { - if (this.environment.isIntegrationMode()) { - return Promise.resolve({ locale: 'en' }); - } return this.messaging.request(constants.MSG_NAME_INITIAL_SETUP); } @@ -52,7 +49,7 @@ export class DuckPlayerNativeMessages { * @param {number} timestamp */ onCurrentTimestamp(timestamp) { - return this.messaging.notify('onCurrentTimestamp', { timestamp }); + return this.messaging.notify(constants.MSG_NAME_CURRENT_TIMESTAMP, { timestamp }); } /** @@ -60,8 +57,7 @@ export class DuckPlayerNativeMessages { * @param {(mediaControlSettings: mediaControlSettings) => void} callback */ onMediaControl(callback) { - console.log('Subscribing to onMediaControl'); - return this.messaging.subscribe('onMediaControl', callback); + return this.messaging.subscribe(constants.MSG_NAME_MEDIA_CONTROL, callback); } /** @@ -69,8 +65,7 @@ export class DuckPlayerNativeMessages { * @param {(muteSettings: muteSettings) => void} callback */ onMuteAudio(callback) { - console.log('Subscribing to onMuteAudio'); - return this.messaging.subscribe('onMuteAudio', callback); + return this.messaging.subscribe(constants.MSG_NAME_MUTE_AUDIO, callback); } /** @@ -78,8 +73,7 @@ export class DuckPlayerNativeMessages { * @param {() => void} callback */ onSerpNotify(callback) { - console.log('Subscribing to onSerpNotify'); - return this.messaging.subscribe('onSerpNotify', callback); + return this.messaging.subscribe(constants.MSG_NAME_SERP_NOTIFY, callback); } /** @@ -87,6 +81,6 @@ export class DuckPlayerNativeMessages { * @param {string} error */ onYoutubeError(error) { - this.messaging.notify('onYoutubeError', { error }); + this.messaging.notify(constants.MSG_NAME_YOUTUBE_ERROR, { error }); } } diff --git a/injected/src/features/duckplayer-native/mock-transport.js b/injected/src/features/duckplayer-native/mock-transport.js new file mode 100644 index 0000000000..6c1abf0bee --- /dev/null +++ b/injected/src/features/duckplayer-native/mock-transport.js @@ -0,0 +1,69 @@ +import * as constants from './constants.js'; + +function log(message) { + console.log(`[mock] ${message}`); +} + +export class TestTransport { + notify(_msg) { + log(`Notifying ${_msg.method}`); + + window.__playwright_01?.mocks?.outgoing?.push?.({ payload: structuredClone(_msg) }); + const msg = /** @type {any} */ (_msg); + switch (msg.method) { + case constants.MSG_NAME_CURRENT_TIMESTAMP: + return; + default: { + console.warn('unhandled notification', msg); + } + } + } + + request(_msg) { + log(`Requesting ${_msg.method}`); + + window.__playwright_01?.mocks?.outgoing?.push?.({ payload: structuredClone(_msg) }); + const msg = /** @type {any} */ (_msg); + switch (msg.method) { + case constants.MSG_NAME_INITIAL_SETUP: { + return Promise.resolve({ locale: 'en' }); + } + default: + return Promise.resolve(null); + } + } + + subscribe(_msg, callback) { + log(`Subscribing to ${_msg.subscriptionName}`); + + window.__playwright_01?.mocks?.outgoing?.push?.({ payload: structuredClone(_msg) }); + + let response = null; + let timeout = 1000; + + switch (_msg.subscriptionName) { + case constants.MSG_NAME_MEDIA_CONTROL: + response = { pause: true }; + break; + case constants.MSG_NAME_MUTE_AUDIO: + response = { mute: true }; + timeout = 1500; + break; + case constants.MSG_NAME_SERP_NOTIFY: + timeout = 2000; + } + + const callbackTimeout = setTimeout(() => { + log(`Calling handler for ${_msg.subscriptionName}`); + callback(response); + }, timeout); + + return () => { + clearTimeout(callbackTimeout); + }; + } +} + +export function mockTransport() { + return new TestTransport(); +} diff --git a/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.js b/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.js index ec6743a747..2ec467b1fe 100644 --- a/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.js +++ b/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.js @@ -1,13 +1,7 @@ -import mobilecss from './thumbnail-overlay.css'; +import css from './thumbnail-overlay.css'; import { createPolicy, html } from '../../../dom-utils.js'; import { customElementsDefine, customElementsGet } from '../../../captured-globals.js'; -import { VideoParams, appendImageAsBackground } from '../../duckplayer/util'; - -export function registerCustomElements() { - if (!customElementsGet(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME)) { - customElementsDefine(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME, DDGVideoThumbnailOverlay); - } -} +import { VideoParams, appendImageAsBackground } from '../util'; /** * The custom element that we use to present our UI elements @@ -21,6 +15,20 @@ export class DDGVideoThumbnailOverlay extends HTMLElement { testMode = false; /** @type {HTMLElement} */ container; + /** @type {string} */ + href; + + static register() { + if (!customElementsGet(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME)) { + customElementsDefine(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME, DDGVideoThumbnailOverlay); + } + } + + log(message, force = false) { + if (this.testMode || force) { + console.log(`[thumbnail-overlay] ${message}`); + } + } connectedCallback() { this.createMarkupAndStyles(); @@ -28,50 +36,36 @@ export class DDGVideoThumbnailOverlay extends HTMLElement { createMarkupAndStyles() { const shadow = this.attachShadow({ mode: this.testMode ? 'open' : 'closed' }); + const style = document.createElement('style'); - style.innerText = mobilecss; + style.innerText = css; + const container = document.createElement('div'); + container.classList.add('wrapper'); const content = this.render(); container.innerHTML = this.policy.createHTML(content); shadow.append(style, container); this.container = container; + + if (this.testMode) { + this.log(`Created ${DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME} with container ${container}`); + } this.appendThumbnail(); } appendThumbnail() { - const params = VideoParams.forWatchPage(this.getPlayerPageHref()); - const videoId = params?.id; - - const imageUrl = this.getLargeThumbnailSrc(videoId); - console.log('Image url', imageUrl); - appendImageAsBackground(this.container, '.ddg-vpo-bg', imageUrl); - } + const params = VideoParams.forWatchPage(this.href); + const imageUrl = params?.toLargeThumbnailUrl(); - getLargeThumbnailSrc(videoId) { - const url = new URL(`/vi/${videoId}/maxresdefault.jpg`, 'https://i.ytimg.com'); - return url.href; - } + if (!imageUrl) { + console.warn('Could not get thumbnail url for video id', params?.id); + return; + } - /** - * This is the URL of the page that the user is currently on - * It's abstracted so that we can mock it in tests - * @return {string} - */ - getPlayerPageHref() { if (this.testMode) { - const url = new URL(window.location.href); - if (url.hostname === 'www.youtube.com') return window.location.href; - - // reflect certain query params, this is useful for testing - if (url.searchParams.has('v')) { - const base = new URL('/watch', 'https://youtube.com'); - base.searchParams.set('v', url.searchParams.get('v') || ''); - return base.toString(); - } - - return 'https://youtube.com/watch?v=123'; + this.log(`Appending thumbnail ${imageUrl}`); } - return window.location.href; + appendImageAsBackground(this.container, '.ddg-vpo-bg', imageUrl); } /** @@ -87,20 +81,18 @@ export class DDGVideoThumbnailOverlay extends HTMLElement { } } - - - - /** * * @param {HTMLElement} targetElement + * @param {import('../environment').Environment} environment */ -export function appendThumbnailOverlay(targetElement) { - registerCustomElements(); - console.log('Appending thumbnail overlay'); +export function appendThumbnailOverlay(targetElement, environment) { + DDGVideoThumbnailOverlay.register(); + const overlay = /** @type {DDGVideoThumbnailOverlay} */ (document.createElement(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME)); - console.log('Overlay', overlay, targetElement); - overlay.testMode = true; + overlay.testMode = environment.isTestMode(); + overlay.href = environment.getPlayerPageHref(); + targetElement.appendChild(overlay); return () => { diff --git a/injected/src/features/duckplayer-native/pause-video.js b/injected/src/features/duckplayer-native/pause-video.js index 58f7a7e243..bdb723cf8f 100644 --- a/injected/src/features/duckplayer-native/pause-video.js +++ b/injected/src/features/duckplayer-native/pause-video.js @@ -3,7 +3,6 @@ * @param {string} videoSelector */ export function stopVideoFromPlaying(videoSelector) { - console.log('Setting up video pause'); /** * Set up the interval - keep calling .pause() to prevent * the video from playing diff --git a/injected/src/features/duckplayer-native/util.js b/injected/src/features/duckplayer-native/util.js new file mode 100644 index 0000000000..2394412054 --- /dev/null +++ b/injected/src/features/duckplayer-native/util.js @@ -0,0 +1,304 @@ +// TODO: This was copied from Duck Player. May need a cleanup + +/* eslint-disable promise/prefer-await-to-then */ +/** + * Add an event listener to an element that is only executed if it actually comes from a user action + * @param {Element} element - to attach event to + * @param {string} event + * @param {function} callback + */ +export function addTrustedEventListener(element, event, callback) { + element.addEventListener(event, (e) => { + if (e.isTrusted) { + callback(e); + } + }); +} + +/** + * Try to load an image first. If the status code is 2xx, then continue + * to load + * @param {HTMLElement} parent + * @param {string} targetSelector + * @param {string} imageUrl + */ +export function appendImageAsBackground(parent, targetSelector, imageUrl) { + const canceled = false; + + /** + * Make a HEAD request to see what the status of this image is, without + * having to fully download it. + * + * This is needed because YouTube returns a 404 + valid image file when there's no + * thumbnail and you can't tell the difference through the 'onload' event alone + */ + fetch(imageUrl, { method: 'HEAD' }) + .then((x) => { + const status = String(x.status); + if (canceled) return console.warn('not adding image, cancelled'); + if (status.startsWith('2')) { + if (!canceled) { + append(); + } else { + console.warn('ignoring cancelled load'); + } + } else { + markError(); + } + }) + .catch(() => { + console.error('e from fetch'); + }); + + /** + * If loading fails, mark the parent with data-attributes + */ + function markError() { + parent.dataset.thumbLoaded = String(false); + parent.dataset.error = String(true); + } + + /** + * If loading succeeds, try to append the image + */ + function append() { + const targetElement = parent.querySelector(targetSelector); + if (!(targetElement instanceof HTMLElement)) { + return console.warn('could not find child with selector', targetSelector, 'from', parent); + } + parent.dataset.thumbLoaded = String(true); + parent.dataset.thumbSrc = imageUrl; + const img = new Image(); + img.src = imageUrl; + img.onload = function () { + if (canceled) return console.warn('not adding image, cancelled'); + targetElement.style.backgroundImage = `url(${imageUrl})`; + targetElement.style.backgroundSize = 'cover'; + }; + img.onerror = function () { + if (canceled) return console.warn('not calling markError, cancelled'); + markError(); + const targetElement = parent.querySelector(targetSelector); + if (!(targetElement instanceof HTMLElement)) return; + targetElement.style.backgroundImage = ''; + }; + } +} + +export class SideEffects { + /** + * @param {object} params + * @param {boolean} [params.debug] + */ + constructor({ debug = false } = {}) { + this.debug = debug; + } + + /** @type {{fn: () => void, name: string}[]} */ + _cleanups = []; + /** + * Wrap a side-effecting operation for easier debugging + * and teardown/release of resources + * @param {string} name + * @param {() => () => void} fn + */ + add(name, fn) { + try { + if (this.debug) { + console.log('☢️', name); + } + const cleanup = fn(); + if (typeof cleanup === 'function') { + this._cleanups.push({ name, fn: cleanup }); + } + } catch (e) { + console.error('%s threw an error', name, e); + } + } + + /** + * Remove elements, event listeners etc + */ + destroy() { + for (const cleanup of this._cleanups) { + if (typeof cleanup.fn === 'function') { + try { + if (this.debug) { + console.log('🗑️', cleanup.name); + } + cleanup.fn(); + } catch (e) { + console.error(`cleanup ${cleanup.name} threw`, e); + } + } else { + throw new Error('invalid cleanup'); + } + } + this._cleanups = []; + } +} + +/** + * A container for valid/parsed video params. + * + * If you have an instance of `VideoParams`, then you can trust that it's valid, and you can always + * produce a PrivatePlayer link from it + * + * The purpose is to co-locate all processing of search params/pathnames for easier security auditing/testing + * + * @example + * + * ``` + * const privateUrl = VideoParams.fromHref("https://example.com/foo/bar?v=123&t=21")?.toPrivatePlayerUrl() + * ^^^^ <- this is now null, or a string if it was valid + * ``` + */ +export class VideoParams { + /** + * @param {string} id - the YouTube video ID + * @param {string|null|undefined} time - an optional time + */ + constructor(id, time) { + this.id = id; + this.time = time; + } + + static validVideoId = /^[a-zA-Z0-9-_]+$/; + static validTimestamp = /^[0-9hms]+$/; + + /** + * @returns {string} + */ + toPrivatePlayerUrl() { + // no try/catch because we already validated the ID + // in Microsoft WebView2 v118+ changing from special protocol (https) to non-special one (duck) is forbidden + // so we need to construct duck player this way + const duckUrl = new URL(`duck://player/${this.id}`); + + if (this.time) { + duckUrl.searchParams.set('t', this.time); + } + return duckUrl.href; + } + + /** + * Get the large thumbnail URL for the current video id + * + * @returns {string} + */ + toLargeThumbnailUrl() { + const url = new URL(`/vi/${this.id}/maxresdefault.jpg`, 'https://i.ytimg.com'); + return url.href; + } + + /** + * Create a VideoParams instance from a href, only if it's on the watch page + * + * @param {string} href + * @returns {VideoParams|null} + */ + static forWatchPage(href) { + let url; + try { + url = new URL(href); + } catch (e) { + return null; + } + if (!url.pathname.startsWith('/watch')) { + return null; + } + return VideoParams.fromHref(url.href); + } + + /** + * Convert a relative pathname into VideoParams + * + * @param pathname + * @returns {VideoParams|null} + */ + static fromPathname(pathname) { + let url; + try { + url = new URL(pathname, window.location.origin); + } catch (e) { + return null; + } + return VideoParams.fromHref(url.href); + } + + /** + * Convert a href into valid video params. Those can then be converted into a private player + * link when needed + * + * @param href + * @returns {VideoParams|null} + */ + static fromHref(href) { + let url; + try { + url = new URL(href); + } catch (e) { + return null; + } + + let id = null; + + // known params + const vParam = url.searchParams.get('v'); + const tParam = url.searchParams.get('t'); + + // don't continue if 'list' is present, but 'index' is not. + // valid: '/watch?v=321&list=123&index=1234' + // invalid: '/watch?v=321&list=123' <- index absent + if (url.searchParams.has('list') && !url.searchParams.has('index')) { + return null; + } + + let time = null; + + // ensure youtube video id is good + if (vParam && VideoParams.validVideoId.test(vParam)) { + id = vParam; + } else { + // if the video ID is invalid, we cannot produce an instance of VideoParams + return null; + } + + // ensure timestamp is good, if set + if (tParam && VideoParams.validTimestamp.test(tParam)) { + time = tParam; + } + + return new VideoParams(id, time); + } +} + +/** + * A helper to run a callback when the DOM is loaded. + * Construct this early, so that the event listener is added as soon as possible. + * Then you can add callbacks to it, and they will be called when the DOM is loaded, or immediately + * if the DOM is already loaded. + */ +export class DomState { + loaded = false; + loadedCallbacks = []; + constructor() { + window.addEventListener('DOMContentLoaded', () => { + this.loaded = true; + this.loadedCallbacks.forEach((cb) => cb()); + }); + } + + onLoaded(loadedCallback) { + if (this.loaded) return loadedCallback(); + this.loadedCallbacks.push(loadedCallback); + } +} + +/** + * + */ +export function getLargeThumbnailSrc(videoId) { + const url = new URL(`/vi/${videoId}/maxresdefault.jpg`, 'https://i.ytimg.com'); + return url.href; +} diff --git a/injected/src/locales/duckplayer/en/native.json b/injected/src/locales/duckplayer/en/native.json new file mode 100644 index 0000000000..6565e97262 --- /dev/null +++ b/injected/src/locales/duckplayer/en/native.json @@ -0,0 +1,32 @@ +{ + "smartling": { + "string_format": "icu", + "translate_paths": [ + { + "path": "*/title", + "key": "{*}/title", + "instruction": "*/note" + } + ] + }, + "blockedVideoErrorHeading": { + "title": "YouTube won’t let Duck Player load this video", + "note": "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1": { + "title": "YouTube doesn’t allow this video to be viewed outside of YouTube.", + "note": "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2": { + "title": "You can still watch this video on YouTube, but without the added privacy of Duck Player.", + "note": "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorMessage1": { + "title": "YouTube is blocking this video from loading. If you’re using a VPN, try turning it off and reloading this page.", + "note": "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2": { + "title": "If this doesn’t work, you can still watch this video on YouTube, but without the added privacy of Duck Player.", + "note": "More troubleshooting tips for this specific error" + } +} From 60e5808e6acad96dbb33351f57ec902259b79674 Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Thu, 17 Apr 2025 11:27:22 +0100 Subject: [PATCH 20/79] Logger refactor --- .../test-pages/duckplayer/config/native.json | 3 +- injected/src/features/duck-player-native.js | 6 +- .../custom-error/custom-error.js | 21 +++---- .../duckplayer-native/duckplayer-native.js | 31 ++++++---- .../duckplayer-native/error-detection.js | 59 +++++++++++-------- .../duckplayer-native/mock-transport.js | 18 +++--- .../overlays/thumbnail-overlay.js | 24 ++++---- .../src/features/duckplayer-native/util.js | 46 +++++++++++++-- 8 files changed, 129 insertions(+), 79 deletions(-) diff --git a/injected/integration-test/test-pages/duckplayer/config/native.json b/injected/integration-test/test-pages/duckplayer/config/native.json index a4358ac2fc..4c87e13e0d 100644 --- a/injected/integration-test/test-pages/duckplayer/config/native.json +++ b/injected/integration-test/test-pages/duckplayer/config/native.json @@ -9,7 +9,8 @@ "errorContainer": "body", "signInRequiredError": "[href*=\"//support.google.com/youtube/answer/3037019\"]", "videoElement": "#player video", - "videoElementContainer": "#player .html5-video-player" + "videoElementContainer": "#player .html5-video-player", + "youtubeError": ".ytp-error" }, "domains": [] } diff --git a/injected/src/features/duck-player-native.js b/injected/src/features/duck-player-native.js index 98aa2df2a0..45de1d1a45 100644 --- a/injected/src/features/duck-player-native.js +++ b/injected/src/features/duck-player-native.js @@ -12,7 +12,7 @@ import { Environment } from './duckplayer-native/environment.js'; export class DuckPlayerNativeFeature extends ContentFeature { init(args) { - console.log('[duckplayer-native] Loading', args); + console.log('DUCK PLAYER NATIVE LOADING', args); // TODO: Should we keep this? /** @@ -25,8 +25,8 @@ export class DuckPlayerNativeFeature extends ContentFeature { */ // TODO: Why isn't this working? // const settings = this.getFeatureSetting('settings'); - const settings = args?.featureSettings?.duckPlayerNative; - console.log('[duckplayer-native] Selectors', settings?.selectors); + const settings = args?.featureSettings?.duckPlayerNative?.settings || args?.featureSettings?.duckPlayerNative; + console.log('DUCK PLAYER NATIVE SELECTORS', settings?.selectors); const locale = args?.locale || args?.language || 'en'; const env = new Environment({ diff --git a/injected/src/features/duckplayer-native/custom-error/custom-error.js b/injected/src/features/duckplayer-native/custom-error/custom-error.js index ee8d4c12e0..d15fcbdc08 100644 --- a/injected/src/features/duckplayer-native/custom-error/custom-error.js +++ b/injected/src/features/duckplayer-native/custom-error/custom-error.js @@ -1,4 +1,5 @@ import css from './custom-error.css'; +import { Logger } from '../util'; import { createPolicy, html } from '../../../dom-utils.js'; import { customElementsDefine, customElementsGet } from '../../../captured-globals.js'; @@ -12,6 +13,8 @@ export class CustomError extends HTMLElement { static CUSTOM_TAG_NAME = 'ddg-video-error'; policy = createPolicy(); + /** @type {Logger} */ + logger; /** @type {boolean} */ testMode = false; /** @type {YouTubeError} */ @@ -27,12 +30,6 @@ export class CustomError extends HTMLElement { } } - log(message, force = false) { - if (this.testMode || force) { - console.log(`[custom-error] ${message}`); - } - } - connectedCallback() { this.createMarkupAndStyles(); } @@ -50,9 +47,7 @@ export class CustomError extends HTMLElement { shadow.append(style, container); this.container = container; - if (this.testMode) { - this.log(`Created ${CustomError.CUSTOM_TAG_NAME} with container ${container}`); - } + this.logger?.log('Created', CustomError.CUSTOM_TAG_NAME, 'with container', container); } /** @@ -88,7 +83,7 @@ export class CustomError extends HTMLElement { */ function getErrorStrings(environment, errorId) { // TODO: get from environment strings - console.log(`Getting translations for ${errorId} from ${environment}`); + console.log('TODO: Get translations for ', errorId, 'from', environment); return { title: 'YouTube won’t let Duck Player load this video', messages: [ @@ -106,9 +101,15 @@ function getErrorStrings(environment, errorId) { */ export function showError(targetElement, environment, errorId) { const { title, messages } = getErrorStrings(environment, errorId); + const logger = new Logger({ + id: 'CUSTOM_ERROR', + shouldLog: () => environment.isTestMode(), + }); + CustomError.register(); const customError = /** @type {CustomError} */ (document.createElement(CustomError.CUSTOM_TAG_NAME)); + customError.logger = logger; customError.testMode = environment.isTestMode(); customError.title = title; customError.messages = messages; diff --git a/injected/src/features/duckplayer-native/duckplayer-native.js b/injected/src/features/duckplayer-native/duckplayer-native.js index adc6e3dcb4..91e2f0de36 100644 --- a/injected/src/features/duckplayer-native/duckplayer-native.js +++ b/injected/src/features/duckplayer-native/duckplayer-native.js @@ -6,6 +6,7 @@ import { ErrorDetection } from './error-detection.js'; import { appendThumbnailOverlay } from './overlays/thumbnail-overlay.js'; import { stopVideoFromPlaying } from './pause-video.js'; import { showError } from './custom-error/custom-error.js'; +import { Logger } from './util.js'; /** * @typedef {object} DuckPlayerNativeSettings @@ -13,6 +14,8 @@ import { showError } from './custom-error/custom-error.js'; */ export class DuckPlayerNative { + /** @type {Logger} */ + logger; /** @type {DuckPlayerNativeSettings} */ settings; /** @type {import('./environment.js').Environment} */ @@ -32,16 +35,18 @@ export class DuckPlayerNative { throw new Error('Missing arguments'); } + this.setupLogger(); + this.settings = settings; this.environment = environment; this.messages = messages; } - // TODO: Is there a class or module that does this already? - log(message, force = false) { - if (this.environment.isTestMode() || force) { - console.log(`[duckplayer-native] ${message}`); - } + setupLogger() { + this.logger = new Logger({ + id: 'DUCK_PLAYER_NATIVE', + shouldLog: () => this.environment.isTestMode(), + }); } async init() { @@ -56,7 +61,7 @@ export class DuckPlayerNative { return; } - console.log('INITIAL SETUP', initialSetup); + this.logger.log('INITIAL SETUP', initialSetup); this.setupMessaging(); this.setupErrorDetection(); @@ -79,7 +84,7 @@ export class DuckPlayerNative { } setupErrorDetection() { - this.log('Setting up error detection'); + this.logger.log('Setting up error detection'); const errorContainer = this.settings.selectors?.errorContainer; const signInRequiredError = this.settings.selectors?.signInRequiredError; if (!errorContainer || !signInRequiredError) { @@ -89,7 +94,7 @@ export class DuckPlayerNative { /** @type {(errorId: import('./error-detection.js').YouTubeError) => void} */ const errorHandler = (errorId) => { - this.log(`Received error ${errorId}`); + this.logger.log('Received error', errorId); // Notify the browser of the error this.messages.onYoutubeError(errorId); @@ -102,7 +107,7 @@ export class DuckPlayerNative { /** @type {import('./error-detection.js').ErrorDetectionSettings} */ const errorDetectionSettings = { - signInRequiredSelector: signInRequiredError, + selectors: this.settings.selectors, testMode: this.environment.isTestMode(), callback: errorHandler, }; @@ -134,7 +139,7 @@ export class DuckPlayerNative { * @param {import('./messages.js').mediaControlSettings} settings */ mediaControlHandler({ pause }) { - this.log(`Running media control handler. Pause: ${pause}`); + this.logger.log('Running media control handler. Pause:', pause); const videoElement = this.settings.selectors?.videoElement; const videoElementContainer = this.settings.selectors?.videoElementContainer; @@ -159,17 +164,17 @@ export class DuckPlayerNative { * @param {import('./messages.js').muteSettings} settings */ muteAudioHandler({ mute }) { - this.log(`Running mute audio handler. Mute: ${mute}`); + this.logger.log('Running mute audio handler. Mute:', mute); muteAudio(mute); } serpNotifyHandler() { - this.log('Running SERP notify handler'); + this.logger.log('Running SERP notify handler'); serpNotify(); } currentTimestampHandler() { - this.log('Running current timestamp handler'); + this.logger.log('Running current timestamp handler'); getCurrentTimestamp(); } } diff --git a/injected/src/features/duckplayer-native/error-detection.js b/injected/src/features/duckplayer-native/error-detection.js index 0c0d4eb8a9..e06306ad5c 100644 --- a/injected/src/features/duckplayer-native/error-detection.js +++ b/injected/src/features/duckplayer-native/error-detection.js @@ -1,10 +1,13 @@ +import { Logger } from './util'; + /** @typedef {"age-restricted" | "sign-in-required" | "no-embed" | "unknown"} YouTubeError */ /** @typedef {(error: YouTubeError) => void} ErrorDetectionCallback */ +/** @typedef {import('./duckplayer-native').DuckPlayerNativeSettings['selectors']} DuckPlayerNativeSelectors */ /** * @typedef {object} ErrorDetectionSettings - * @property {string} signInRequiredSelector + * @property {DuckPlayerNativeSelectors} selectors * @property {ErrorDetectionCallback} callback * @property {boolean} testMode */ @@ -21,8 +24,10 @@ export const YOUTUBE_ERRORS = { * Detects YouTube errors based on DOM queries */ export class ErrorDetection { - /** @type {string} */ - signInRequiredSelector; + /** @type {Logger} */ + logger; + /** @type {DuckPlayerNativeSelectors} */ + selectors; /** @type {ErrorDetectionCallback} */ callback; /** @type {boolean} */ @@ -31,16 +36,17 @@ export class ErrorDetection { /** * @param {ErrorDetectionSettings} settings */ - constructor({ signInRequiredSelector, callback, testMode }) { - this.signInRequiredSelector = signInRequiredSelector; + constructor({ selectors, callback, testMode = false }) { + if (!selectors?.youtubeError || !selectors?.signInRequiredError || !callback) { + throw new Error('Missing selectors or callback props'); + } + this.selectors = selectors; this.callback = callback; this.testMode = testMode; - } - - log(message, force = false) { - if (this.testMode || force) { - console.log(`[error-detection] ${message}`); - } + this.logger = new Logger({ + id: 'ERROR_DETECTION', + shouldLog: () => this.testMode, + }); } /** @@ -78,10 +84,10 @@ export class ErrorDetection { */ handleError(errorId) { if (this.callback) { - this.log(`Calling error handler for ${errorId}`); + this.logger.log('Calling error handler for', errorId); this.callback(errorId); } else { - console.warn('No error callback found'); + this.logger.warn('No error callback found'); } } @@ -95,7 +101,7 @@ export class ErrorDetection { if (mutation.type === 'childList') { mutation.addedNodes.forEach((node) => { if (this.checkForError(node)) { - console.log('A node with an error has been added to the document:', node); + this.logger.log('A node with an error has been added to the document:', node); const error = this.getErrorType(); this.handleError(error); } @@ -113,18 +119,18 @@ export class ErrorDetection { let playerResponse; if (!currentWindow.ytcfg) { - console.log('ytcfg missing!'); + this.logger.warn('ytcfg missing!'); + } else { + this.logger.log('Got ytcfg', currentWindow.ytcfg); } - console.log('Got ytcfg', currentWindow.ytcfg); - try { const playerResponseJSON = currentWindow.ytcfg?.get('PLAYER_VARS')?.embedded_player_response; - console.log('Player response', playerResponseJSON); + this.logger.log('Player response', playerResponseJSON); playerResponse = JSON.parse(playerResponseJSON); } catch (e) { - console.log('Could not parse player response', e); + this.logger.log('Could not parse player response', e); } if (typeof playerResponse === 'object') { @@ -136,28 +142,28 @@ export class ErrorDetection { if (status === 'UNPLAYABLE') { // 1.1. Check for presence of desktopLegacyAgeGateReason if (desktopLegacyAgeGateReason === 1) { - console.log('AGE RESTRICTED ERROR'); + this.logger.log('AGE RESTRICTED ERROR'); return YOUTUBE_ERRORS.ageRestricted; } // 1.2. Fall back to embed not allowed error - console.log('NO EMBED ERROR'); + this.logger.log('NO EMBED ERROR'); return YOUTUBE_ERRORS.noEmbed; } // 2. Check for sign-in support link try { - if (this.signInRequiredSelector && !!document.querySelector(this.signInRequiredSelector)) { - console.log('SIGN-IN ERROR'); + if (document.querySelector(this.selectors.signInRequiredError)) { + this.logger.log('SIGN-IN ERROR'); return YOUTUBE_ERRORS.signInRequired; } } catch (e) { - console.log('Sign-in required query failed', e); + this.logger.log('Sign-in required query failed', e); } } // 3. Fall back to unknown error - console.log('UNKNOWN ERROR'); + this.logger.log('UNKNOWN ERROR'); return YOUTUBE_ERRORS.unknown; } @@ -168,9 +174,10 @@ export class ErrorDetection { */ checkForError(node) { if (node?.nodeType === Node.ELEMENT_NODE) { + const { youtubeError } = this.selectors; const element = /** @type {HTMLElement} */ (node); // Check if element has the error class or contains any children with that class - const isError = element.classList.contains('ytp-error') || !!element.querySelector('.ytp-error'); + const isError = element.matches(youtubeError) || !!element.querySelector(youtubeError); return isError; } diff --git a/injected/src/features/duckplayer-native/mock-transport.js b/injected/src/features/duckplayer-native/mock-transport.js index 6c1abf0bee..0c71affd5f 100644 --- a/injected/src/features/duckplayer-native/mock-transport.js +++ b/injected/src/features/duckplayer-native/mock-transport.js @@ -1,12 +1,14 @@ import * as constants from './constants.js'; +import { Logger } from './util.js'; -function log(message) { - console.log(`[mock] ${message}`); -} +const logger = new Logger({ + id: 'MOCK_TRANSPORT', + shouldLog: () => true, +}); export class TestTransport { notify(_msg) { - log(`Notifying ${_msg.method}`); + logger.log('Notifying', _msg.method); window.__playwright_01?.mocks?.outgoing?.push?.({ payload: structuredClone(_msg) }); const msg = /** @type {any} */ (_msg); @@ -14,13 +16,13 @@ export class TestTransport { case constants.MSG_NAME_CURRENT_TIMESTAMP: return; default: { - console.warn('unhandled notification', msg); + logger.warn('unhandled notification', msg); } } } request(_msg) { - log(`Requesting ${_msg.method}`); + logger.log('Requesting', _msg.method); window.__playwright_01?.mocks?.outgoing?.push?.({ payload: structuredClone(_msg) }); const msg = /** @type {any} */ (_msg); @@ -34,7 +36,7 @@ export class TestTransport { } subscribe(_msg, callback) { - log(`Subscribing to ${_msg.subscriptionName}`); + logger.log('Subscribing to', _msg.subscriptionName); window.__playwright_01?.mocks?.outgoing?.push?.({ payload: structuredClone(_msg) }); @@ -54,7 +56,7 @@ export class TestTransport { } const callbackTimeout = setTimeout(() => { - log(`Calling handler for ${_msg.subscriptionName}`); + logger.log('Calling handler for', _msg.subscriptionName); callback(response); }, timeout); diff --git a/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.js b/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.js index 2ec467b1fe..24992f64e2 100644 --- a/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.js +++ b/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.js @@ -1,7 +1,7 @@ import css from './thumbnail-overlay.css'; import { createPolicy, html } from '../../../dom-utils.js'; import { customElementsDefine, customElementsGet } from '../../../captured-globals.js'; -import { VideoParams, appendImageAsBackground } from '../util'; +import { VideoParams, appendImageAsBackground, Logger } from '../util'; /** * The custom element that we use to present our UI elements @@ -11,6 +11,8 @@ export class DDGVideoThumbnailOverlay extends HTMLElement { static CUSTOM_TAG_NAME = 'ddg-video-thumbnail-overlay-mobile'; policy = createPolicy(); + /** @type {Logger} */ + logger; /** @type {boolean} */ testMode = false; /** @type {HTMLElement} */ @@ -24,12 +26,6 @@ export class DDGVideoThumbnailOverlay extends HTMLElement { } } - log(message, force = false) { - if (this.testMode || force) { - console.log(`[thumbnail-overlay] ${message}`); - } - } - connectedCallback() { this.createMarkupAndStyles(); } @@ -47,9 +43,7 @@ export class DDGVideoThumbnailOverlay extends HTMLElement { shadow.append(style, container); this.container = container; - if (this.testMode) { - this.log(`Created ${DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME} with container ${container}`); - } + this.logger?.log('Created', DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME, 'with container', container); this.appendThumbnail(); } @@ -58,12 +52,12 @@ export class DDGVideoThumbnailOverlay extends HTMLElement { const imageUrl = params?.toLargeThumbnailUrl(); if (!imageUrl) { - console.warn('Could not get thumbnail url for video id', params?.id); + this.logger?.warn('Could not get thumbnail url for video id', params?.id); return; } if (this.testMode) { - this.log(`Appending thumbnail ${imageUrl}`); + this.logger?.log('Appending thumbnail', imageUrl); } appendImageAsBackground(this.container, '.ddg-vpo-bg', imageUrl); } @@ -87,9 +81,15 @@ export class DDGVideoThumbnailOverlay extends HTMLElement { * @param {import('../environment').Environment} environment */ export function appendThumbnailOverlay(targetElement, environment) { + const logger = new Logger({ + id: 'THUMBNAIL_OVERLAY', + shouldLog: () => environment.isTestMode(), + }); + DDGVideoThumbnailOverlay.register(); const overlay = /** @type {DDGVideoThumbnailOverlay} */ (document.createElement(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME)); + overlay.logger = logger; overlay.testMode = environment.isTestMode(); overlay.href = environment.getPlayerPageHref(); diff --git a/injected/src/features/duckplayer-native/util.js b/injected/src/features/duckplayer-native/util.js index 2394412054..0e5782a84c 100644 --- a/injected/src/features/duckplayer-native/util.js +++ b/injected/src/features/duckplayer-native/util.js @@ -295,10 +295,44 @@ export class DomState { } } -/** - * - */ -export function getLargeThumbnailSrc(videoId) { - const url = new URL(`/vi/${videoId}/maxresdefault.jpg`, 'https://i.ytimg.com'); - return url.href; +export class Logger { + /** @type {string} */ + id; + /** @type {() => boolean} */ + shouldLog; + + /** + * @param {object} options + * @param {string} options.id - Prefix added to log output + * @param {() => boolean} options.shouldLog - Tells logger whether to output to console + */ + constructor({ id, shouldLog }) { + if (!id || !shouldLog) { + throw new Error('Missing props in Logger'); + } + this.shouldLog = shouldLog; + this.id = id; + } + + error(...args) { + this.output(console.error, args); + } + + info(...args) { + this.output(console.info, args); + } + + log(...args) { + this.output(console.log, args); + } + + warn(...args) { + this.output(console.warn, args); + } + + output(handler, args) { + if (this.shouldLog()) { + handler(`${this.id.padEnd(20, ' ')} |`, ...args); + } + } } From ce68f857a7e53df4db466b51f44ade83522abcde Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Thu, 17 Apr 2025 12:37:37 +0100 Subject: [PATCH 21/79] Duck Player Native tests --- .../duckplayer-native.spec.js | 30 +++ .../page-objects/duckplayer-native.js | 175 ++++++++++++++++++ .../config/native.json | 0 .../pages/player.html} | 2 +- .../pages/thumbnail-dark.jpg | Bin 0 -> 1017 bytes .../pages/thumbnail-light.jpg | Bin 0 -> 1698 bytes injected/playwright.config.js | 6 +- injected/src/features/duck-player-native.js | 5 + .../duckplayer-native/duckplayer-native.js | 34 ++-- .../duckplayer-native/mock-transport.js | 13 +- 10 files changed, 248 insertions(+), 17 deletions(-) create mode 100644 injected/integration-test/duckplayer-native.spec.js create mode 100644 injected/integration-test/page-objects/duckplayer-native.js rename injected/integration-test/test-pages/{duckplayer => duckplayer-native}/config/native.json (100%) rename injected/integration-test/test-pages/{duckplayer/pages/player-native.html => duckplayer-native/pages/player.html} (99%) create mode 100644 injected/integration-test/test-pages/duckplayer-native/pages/thumbnail-dark.jpg create mode 100644 injected/integration-test/test-pages/duckplayer-native/pages/thumbnail-light.jpg diff --git a/injected/integration-test/duckplayer-native.spec.js b/injected/integration-test/duckplayer-native.spec.js new file mode 100644 index 0000000000..eff7556fac --- /dev/null +++ b/injected/integration-test/duckplayer-native.spec.js @@ -0,0 +1,30 @@ +import { test } from '@playwright/test'; +import { DuckPlayerNative } from './page-objects/duckplayer-native.js'; + +test.describe('Duck Player Native messaging', () => { + test('Calls initial setup', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a YouTube page + await duckPlayer.gotoYouTubePage(); + + // Then Initial Setup should be called + await duckPlayer.didSendInitialHandshake(); + }); + + test('Polls timestamp on YouTube', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a YouTube page + await duckPlayer.gotoYouTubePage(); + + // Then the current timestamp should be polled back to the browser + await duckPlayer.didSendCurrentTimestamp(); + }); +}); diff --git a/injected/integration-test/page-objects/duckplayer-native.js b/injected/integration-test/page-objects/duckplayer-native.js new file mode 100644 index 0000000000..7bd2540a6a --- /dev/null +++ b/injected/integration-test/page-objects/duckplayer-native.js @@ -0,0 +1,175 @@ +import { readFileSync } from 'fs'; +import { expect } from '@playwright/test'; +import { perPlatform } from '../type-helpers.mjs'; +import { ResultsCollector } from './results-collector.js'; + +/** + * @import { PageType} from '../../src/features/duck-player-native.js' + */ + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const configFiles = /** @type {const} */ (['native.json']); + +export class DuckPlayerNative { + /** @type {Partial>} */ + pages = { + YOUTUBE: '/duckplayer-native/pages/player.html', + }; + + /** + * @param {import("@playwright/test").Page} page + * @param {import("../type-helpers.mjs").Build} build + * @param {import("@duckduckgo/messaging/lib/test-utils.mjs").PlatformInfo} platform + */ + constructor(page, build, platform) { + this.page = page; + this.build = build; + this.platform = platform; + this.collector = new ResultsCollector(page, build, platform); + this.collector.withMockResponse({ + initialSetup: { + locale: 'en', + }, + onCurrentTimestamp: {}, + }); + this.collector.withUserPreferences({ + messageSecret: 'ABC', + javascriptInterface: 'javascriptInterface', + messageCallback: 'messageCallback', + }); + page.on('console', (msg) => { + console.log(msg.type(), msg.text()); + }); + } + + async reducedMotion() { + await this.page.emulateMedia({ reducedMotion: 'reduce' }); + } + + /** + * @param {object} [params] + * @param {"default" | "incremental-dom"} [params.variant] + * @param {string} [params.videoID] + */ + async gotoYouTubePage(params = {}) { + await this.gotoPage('YOUTUBE', params); + } + + async gotoNoCookiePage() { + await this.gotoPage('NOCOOKIE', {}); + } + + async gotoSERP() { + await this.gotoPage('SERP', {}); + } + + /** + * @param {PageType} pageType + * @param {object} [params] + * @param {"default" | "incremental-dom"} [params.variant] + * @param {string} [params.videoID] + */ + async gotoPage(pageType, params = {}) { + await this.pageTypeIs(pageType); + + const { variant = 'default', videoID = '123' } = params; + const urlParams = new URLSearchParams([ + ['v', videoID], + ['variant', variant], + ['pageType', pageType], + ]); + + const page = this.pages[pageType]; + + await this.page.goto(page + '?' + urlParams.toString()); + } + + /** + * @param {object} [params] + * @param {configFiles[number]} [params.json="native"] - default is settings for localhost + * @param {string} [params.locale] - optional locale + */ + async withRemoteConfig(params = {}) { + const { json = 'native.json', locale = 'en' } = params; + + await this.collector.setup({ config: loadConfig(json), locale }); + } + + /** + * @param {PageType} pageType + * @return {Promise} + */ + async pageTypeIs(pageType) { + const initialSetupResponse = { + locale: 'en', + pageType, + }; + + await this.collector.updateMockResponse({ + initialSetup: initialSetupResponse, + }); + } + + async didSendInitialHandshake() { + const messages = await this.collector.waitForMessage('initialSetup'); + expect(messages).toMatchObject([ + { + payload: { + context: this.collector.messagingContextName, + featureName: 'duckPlayerNative', + method: 'initialSetup', + params: {}, + }, + }, + ]); + } + + async didSendCurrentTimestamp() { + const messages = await this.collector.waitForMessage('onCurrentTimestamp'); + expect(messages).toMatchObject([ + { + payload: { + context: this.collector.messagingContextName, + featureName: 'duckPlayerNative', + method: 'onCurrentTimestamp', + params: { timestamp: 0 }, + }, + }, + ]); + } + + /** + * Helper for creating an instance per platform + * @param {import("@playwright/test").Page} page + * @param {import("@playwright/test").TestInfo} testInfo + */ + static create(page, testInfo) { + // Read the configuration object to determine which platform we're testing against + const { platformInfo, build } = perPlatform(testInfo.project.use); + return new DuckPlayerNative(page, build, platformInfo); + } + + /** + * @return {Promise} + */ + requestWillFail() { + return new Promise((resolve, reject) => { + // on windows it will be a failed request + const timer = setTimeout(() => { + reject(new Error('timed out')); + }, 5000); + this.page.on('framenavigated', (req) => { + clearTimeout(timer); + resolve(req.url()); + }); + }); + } +} + +/** + * @param {configFiles[number]} name + * @return {Record} + */ +function loadConfig(name) { + return JSON.parse(readFileSync(`./integration-test/test-pages/duckplayer-native/config/${name}`, 'utf8')); +} diff --git a/injected/integration-test/test-pages/duckplayer/config/native.json b/injected/integration-test/test-pages/duckplayer-native/config/native.json similarity index 100% rename from injected/integration-test/test-pages/duckplayer/config/native.json rename to injected/integration-test/test-pages/duckplayer-native/config/native.json diff --git a/injected/integration-test/test-pages/duckplayer/pages/player-native.html b/injected/integration-test/test-pages/duckplayer-native/pages/player.html similarity index 99% rename from injected/integration-test/test-pages/duckplayer/pages/player-native.html rename to injected/integration-test/test-pages/duckplayer-native/pages/player.html index 76a5bc3cdd..2579304335 100644 --- a/injected/integration-test/test-pages/duckplayer/pages/player-native.html +++ b/injected/integration-test/test-pages/duckplayer-native/pages/player.html @@ -162,7 +162,7 @@ await import("/build/contentScope.js").catch(console.error) - const settingsFile = '/duckplayer/config/native.json'; + const settingsFile = '/duckplayer-native/config/native.json'; const settings = await fetch(settingsFile).then(x => x.json()) console.log('Settings', settings); diff --git a/injected/integration-test/test-pages/duckplayer-native/pages/thumbnail-dark.jpg b/injected/integration-test/test-pages/duckplayer-native/pages/thumbnail-dark.jpg new file mode 100644 index 0000000000000000000000000000000000000000..38797b8e2960043a548ac2d90a377ea7855e13a3 GIT binary patch literal 1017 zcmex=9q|7UgCGacohO(Xl^B==8JPtc{~uv61O^irFu(yL z6Eh1d8%U6W87>H9Ffuc-u>QZrz{AYQz$Cyd02KPOgH?6I^*Gt^pi{{O8i$>^JWVHs z_G~lY;A(2R>3ejWiBiv2p6!#8t9+srcoZ18rbPPY&+Cnu?sMt2?W-tHrw{KRcM6uO01{(hu8`eT>ArjUS$X&8U;zw59_V2$0gy*nS(q4Eq4GfA2r?@Q z2pI+@vM3lhIyea%B@|8E2=pljFbEj!8Q!r*3NR^Xa9IS1urdk&u|vQ@r&ifRwo?Lp zjf=UzN^Xdnx^jc7Tw3VjTVHmlvU0r8+itD6B24YZyaPS?T9)Ch3K0sq3xW@^Gr8Of z?GW%;$2G_P(sB;LvMH<^?IgH0BArfc`qb>t`q!Jn(jA2oI+Q|V*5)v-V}HOn!pL zVavn_4BP87v*I;)*(&0J8svb|jLfiT0cm6e#te%hgODLq3o}T|>uZnB2}sHCJdnKV P7FMRQJFLJ)|Nl(@Dt;Jb literal 0 HcmV?d00001 diff --git a/injected/integration-test/test-pages/duckplayer-native/pages/thumbnail-light.jpg b/injected/integration-test/test-pages/duckplayer-native/pages/thumbnail-light.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f2310b4b4e32142a9a96bd1dbfb8ca23741993f5 GIT binary patch literal 1698 zcmc&!i#wEA96sO7ur7^e1{s$aGYVtV7`ZjtHLQ``hmfBj!nFXTfdm+7a3utlpbVxE%mFcsIfxAD!c2qG5USY!`vCGk09FY;XD1RO z0t6O9Vj=lmU;!J1K*A<{84v=AQc#4}#+PY;fZ%!g6R-~6j=&%>@X}k5yS4!b5Z7@4 z!hr~@#(M<-7y&>)D4#NQ+MLfJIu?Xc=fZt$E>DHpXf^_%l;%d6!cV#1cQaL{UMJS7 zY)NimHCY@i35~p#U>{%pVSWgIxpBZ#%SAt(HZ4sTKNjl;+u+lTw+LZ3eV#8!k59vsqLV}m%fyAPXt}6 z`^UR=0qf&Ea{)@R>g%i#?7kt-HenoWpAF`tEf7*kNkL)F3D`Y=!W!Z%eAI~)DxDdU zr-0EQSUSY?kyyzWE1ZH;Qt!YfD#3pz^etE&IL4bcIJc!st@T7je%QoJUC;VL6gJOu zCacI+Y4b9Djh6%5jU&IdhuJcQ(o(JfQ5bUKSlGnvI(KK0mm; zm-^wGzf-y!rnbCaUv-PkjN^0pXk}xMKk?BK`x;g&3RgsCwY*w{ZFZLaMIE!7ym{*t z5BnFg`l$JnH^>SjeJmcQoF^DlGYxI_>>a=7QuAIGiJS?ExrcX? z`ejBD0oe+c_9MZ&Y3)_BeI9Wr{0k%1((JHbwfO_L(uawsLp<56blIcf{l)?Q^yl5% zigPVMJ!5qFDBpdjZziuey35ctU8W&vD;wJ>W#2MEoon&Sb-kU)Y5L0P_8S{X*AeSI z0chvL@m%*ZCttzll5Na7bWOdsPE;*2x)*8pY+yWxdrF8xnEG6L$E0%6dpeghQ}=AZ zc8zi~3#9YOlCxeDWM!r+IP4eo?t{&WbF74tBT}Wt3`f`n7d|&Qbd&!bbwPh+s-n~- zocNRLeRNPy5^`EEbJ*ei?&3??Nu1T5xYlResL zZIAM%GLRxlp6$lt{7u8_C5ohbb&rK?(mLCmvjm;|r*?|k^r|8y3*cTa5`1qtF<};;a>pU7q+M zi`clxT;6U>b1gh!7SP_**YAFwk?$5DE$y(g=PR4DW+CFD?)0XVzV_sQ%}O$J?=D5m_a%$?o9!iwipXB)=IIM=?^TohcsT Ovy7QnUY~g-ANm*MPk$l+ literal 0 HcmV?d00001 diff --git a/injected/playwright.config.js b/injected/playwright.config.js index cbadaa0942..9998380b86 100644 --- a/injected/playwright.config.js +++ b/injected/playwright.config.js @@ -37,7 +37,11 @@ export default defineConfig({ }, { name: 'ios', - testMatch: ['integration-test/duckplayer-mobile.spec.js', 'integration-test/duckplayer-mobile-drawer.spec.js'], + testMatch: [ + 'integration-test/duckplayer-mobile.spec.js', + 'integration-test/duckplayer-mobile-drawer.spec.js', + 'integration-test/duckplayer-native.spec.js', + ], use: { injectName: 'apple-isolated', platform: 'ios', ...devices['iPhone 13'] }, }, { diff --git a/injected/src/features/duck-player-native.js b/injected/src/features/duck-player-native.js index 45de1d1a45..20595d47a4 100644 --- a/injected/src/features/duck-player-native.js +++ b/injected/src/features/duck-player-native.js @@ -5,9 +5,14 @@ import { mockTransport } from './duckplayer-native/mock-transport.js'; import { DuckPlayerNative } from './duckplayer-native/duckplayer-native.js'; import { Environment } from './duckplayer-native/environment.js'; +/** + * @typedef {'UNKNOWN'|'YOUTUBE'|'NOCOOKIE'|'SERP'} PageType + */ + /** * @typedef InitialSettings - The initial payload used to communicate render-blocking information * @property {string} locale - UI locale + * @property {PageType} pageType - The type of page that has been loaded */ export class DuckPlayerNativeFeature extends ContentFeature { diff --git a/injected/src/features/duckplayer-native/duckplayer-native.js b/injected/src/features/duckplayer-native/duckplayer-native.js index 91e2f0de36..7a638fd93a 100644 --- a/injected/src/features/duckplayer-native/duckplayer-native.js +++ b/injected/src/features/duckplayer-native/duckplayer-native.js @@ -63,9 +63,27 @@ export class DuckPlayerNative { this.logger.log('INITIAL SETUP', initialSetup); - this.setupMessaging(); - this.setupErrorDetection(); - this.setupTimestampPolling(); + switch (initialSetup.pageType) { + case 'YOUTUBE': { + this.messages.onMediaControl(this.mediaControlHandler.bind(this)); + this.messages.onMuteAudio(this.muteAudioHandler.bind(this)); + this.setupTimestampPolling(); + break; + } + case 'NOCOOKIE': { + this.setupTimestampPolling(); + this.setupErrorDetection(); + break; + } + case 'SERP': { + this.messages.onSerpNotify(this.serpNotifyHandler.bind(this)); + break; + } + case 'UNKNOWN': + default: { + this.logger.log('Unknown page. Not doing anything.'); + } + } // TODO: Question - when/how does the native side call the teardown handler? return async () => { @@ -73,16 +91,6 @@ export class DuckPlayerNative { }; } - /** - * Set up messaging event listeners - */ - setupMessaging() { - this.messages.onMediaControl(this.mediaControlHandler.bind(this)); - this.messages.onMuteAudio(this.muteAudioHandler.bind(this)); - this.messages.onSerpNotify(this.serpNotifyHandler.bind(this)); - // this.messages.onCurrentTimestamp(this.currentTimestampHandler.bind(this)); - } - setupErrorDetection() { this.logger.log('Setting up error detection'); const errorContainer = this.settings.selectors?.errorContainer; diff --git a/injected/src/features/duckplayer-native/mock-transport.js b/injected/src/features/duckplayer-native/mock-transport.js index 0c71affd5f..56c474ff8c 100644 --- a/injected/src/features/duckplayer-native/mock-transport.js +++ b/injected/src/features/duckplayer-native/mock-transport.js @@ -6,7 +6,16 @@ const logger = new Logger({ shouldLog: () => true, }); -export class TestTransport { +const url = new URL(window.location.href); + +class TestTransport { + getInitialSetupData() { + const locale = url.searchParams.get('locale') || 'en'; + const pageType = url.searchParams.get('pageType') || 'UNKNOWN'; + + return { locale, pageType }; + } + notify(_msg) { logger.log('Notifying', _msg.method); @@ -28,7 +37,7 @@ export class TestTransport { const msg = /** @type {any} */ (_msg); switch (msg.method) { case constants.MSG_NAME_INITIAL_SETUP: { - return Promise.resolve({ locale: 'en' }); + return Promise.resolve(this.getInitialSetupData()); } default: return Promise.resolve(null); From 1137a93533892f2673a54531ec1f370211ee23bb Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Thu, 17 Apr 2025 15:00:40 +0100 Subject: [PATCH 22/79] Tests --- .../duckplayer-native.spec.js | 16 ++++ .../page-objects/duckplayer-native.js | 90 ++++++++++++++----- .../duckplayer-native/pages/player.html | 4 - injected/src/features.js | 4 +- injected/src/features/duck-player-native.js | 4 +- .../custom-error/custom-error.js | 2 +- .../duckplayer-native/error-detection.js | 10 ++- .../features/duckplayer-native/messages.js | 2 +- 8 files changed, 93 insertions(+), 39 deletions(-) diff --git a/injected/integration-test/duckplayer-native.spec.js b/injected/integration-test/duckplayer-native.spec.js index eff7556fac..6415384194 100644 --- a/injected/integration-test/duckplayer-native.spec.js +++ b/injected/integration-test/duckplayer-native.spec.js @@ -28,3 +28,19 @@ test.describe('Duck Player Native messaging', () => { await duckPlayer.didSendCurrentTimestamp(); }); }); + +test.describe('Duck Player Native thumbnail overlay', () => { + test('Shows overlay on YouTube player page', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a YouTube page + await duckPlayer.gotoYouTubePage(); + await duckPlayer.sendOnMediaControl(); + + // Then I should see the thumbnail overlay in the page + await duckPlayer.didShowThumbnailOverlay(); + }); +}); \ No newline at end of file diff --git a/injected/integration-test/page-objects/duckplayer-native.js b/injected/integration-test/page-objects/duckplayer-native.js index 7bd2540a6a..65f39bf6ca 100644 --- a/injected/integration-test/page-objects/duckplayer-native.js +++ b/injected/integration-test/page-objects/duckplayer-native.js @@ -76,7 +76,6 @@ export class DuckPlayerNative { const urlParams = new URLSearchParams([ ['v', videoID], ['variant', variant], - ['pageType', pageType], ]); const page = this.pages[pageType]; @@ -110,6 +109,66 @@ export class DuckPlayerNative { }); } + /** + * @param {string} name + * @param {Record} payload + */ + async simulateSubscriptionMessage(name, payload) { + await this.collector.simulateSubscriptionMessage('duckPlayerNative', name, payload); + } + + /** + * Helper for creating an instance per platform + * @param {import("@playwright/test").Page} page + * @param {import("@playwright/test").TestInfo} testInfo + */ + static create(page, testInfo) { + // Read the configuration object to determine which platform we're testing against + const { platformInfo, build } = perPlatform(testInfo.project.use); + return new DuckPlayerNative(page, build, platformInfo); + } + + /** + * @return {Promise} + */ + requestWillFail() { + return new Promise((resolve, reject) => { + // on windows it will be a failed request + const timer = setTimeout(() => { + reject(new Error('timed out')); + }, 5000); + this.page.on('framenavigated', (req) => { + clearTimeout(timer); + resolve(req.url()); + }); + }); + } + + /* Subscriptions */ + + /** + * @param {object} options + */ + async sendOnMediaControl(options = { pause: true }) { + await this.simulateSubscriptionMessage('onMediaControl', options); + } + + /** + * @param {object} options + */ + async sendOnSerpNotify(options = {}) { + await this.simulateSubscriptionMessage('onSerpNotify', options); + } + + /** + * @param {object} options + */ + async sendOnMuteAudio(options = { mute: true }) { + await this.simulateSubscriptionMessage('onMuteAudio', options); + } + + /* Messaging assertions */ + async didSendInitialHandshake() { const messages = await this.collector.waitForMessage('initialSetup'); expect(messages).toMatchObject([ @@ -138,31 +197,14 @@ export class DuckPlayerNative { ]); } - /** - * Helper for creating an instance per platform - * @param {import("@playwright/test").Page} page - * @param {import("@playwright/test").TestInfo} testInfo - */ - static create(page, testInfo) { - // Read the configuration object to determine which platform we're testing against - const { platformInfo, build } = perPlatform(testInfo.project.use); - return new DuckPlayerNative(page, build, platformInfo); + /* Thumbnail Overlay assertions */ + + async didShowThumbnailOverlay() { + await this.page.locator('ddg-video-thumbnail-overlay-mobile').waitFor({ state: 'visible', timeout: 1000 }); } - /** - * @return {Promise} - */ - requestWillFail() { - return new Promise((resolve, reject) => { - // on windows it will be a failed request - const timer = setTimeout(() => { - reject(new Error('timed out')); - }, 5000); - this.page.on('framenavigated', (req) => { - clearTimeout(timer); - resolve(req.url()); - }); - }); + async didShowLogoInOverlay() { + await this.page.locator('ddg-video-thumbnail-overlay-mobile .logo').waitFor({ state: 'visible', timeout: 1000 }); } } diff --git a/injected/integration-test/test-pages/duckplayer-native/pages/player.html b/injected/integration-test/test-pages/duckplayer-native/pages/player.html index 2579304335..0ff82fd744 100644 --- a/injected/integration-test/test-pages/duckplayer-native/pages/player.html +++ b/injected/integration-test/test-pages/duckplayer-native/pages/player.html @@ -184,10 +184,6 @@ featureSettings: settings.features, } })) - - setTimeout(() => { - document.dispatchEvent(new CustomEvent('initialSetup', { detail: {} })); - }) })(); diff --git a/injected/src/features.js b/injected/src/features.js index 6dcf970b99..b64176e485 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -33,8 +33,8 @@ const otherFeatures = /** @type {const} */ ([ /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ /** @type {Record} */ export const platformSupport = { - apple: ['webCompat', 'duckPlayerNative', ...baseFeatures], - 'apple-isolated': ['duckPlayerNative', 'brokerProtection', 'performanceMetrics', 'clickToLoad', 'messageBridge', 'favicon'], + apple: ['webCompat', ...baseFeatures], + 'apple-isolated': ['duckPlayer', 'duckPlayerNative', 'brokerProtection', 'performanceMetrics', 'clickToLoad', 'messageBridge', 'favicon'], android: [...baseFeatures, 'webCompat', 'breakageReporting', 'duckPlayer', 'messageBridge'], 'android-broker-protection': ['brokerProtection'], 'android-autofill-password-import': ['autofillPasswordImport'], diff --git a/injected/src/features/duck-player-native.js b/injected/src/features/duck-player-native.js index 20595d47a4..f78bd32500 100644 --- a/injected/src/features/duck-player-native.js +++ b/injected/src/features/duck-player-native.js @@ -28,9 +28,7 @@ export class DuckPlayerNativeFeature extends ContentFeature { /** * @type {import("@duckduckgo/privacy-configuration/schema/features/duckplayer-native.js").DuckPlayerNativeSettings} */ - // TODO: Why isn't this working? - // const settings = this.getFeatureSetting('settings'); - const settings = args?.featureSettings?.duckPlayerNative?.settings || args?.featureSettings?.duckPlayerNative; + const settings = this.getFeatureSetting('settings') || args?.featureSettings?.duckPlayerNative?.settings || args?.featureSettings?.duckPlayerNative; // TODO: Why doesn't it work with just getFeatureSettings? console.log('DUCK PLAYER NATIVE SELECTORS', settings?.selectors); const locale = args?.locale || args?.language || 'en'; diff --git a/injected/src/features/duckplayer-native/custom-error/custom-error.js b/injected/src/features/duckplayer-native/custom-error/custom-error.js index d15fcbdc08..c9205fdb14 100644 --- a/injected/src/features/duckplayer-native/custom-error/custom-error.js +++ b/injected/src/features/duckplayer-native/custom-error/custom-error.js @@ -3,7 +3,7 @@ import { Logger } from '../util'; import { createPolicy, html } from '../../../dom-utils.js'; import { customElementsDefine, customElementsGet } from '../../../captured-globals.js'; -/** @typedef {import('../error-detection').YouTubeError} YouTubeError */ +/** @import {YouTubeError} from '../error-detection' */ /** * The custom element that we use to present our UI elements diff --git a/injected/src/features/duckplayer-native/error-detection.js b/injected/src/features/duckplayer-native/error-detection.js index e06306ad5c..d855dcb8c8 100644 --- a/injected/src/features/duckplayer-native/error-detection.js +++ b/injected/src/features/duckplayer-native/error-detection.js @@ -1,9 +1,11 @@ import { Logger } from './util'; -/** @typedef {"age-restricted" | "sign-in-required" | "no-embed" | "unknown"} YouTubeError */ - -/** @typedef {(error: YouTubeError) => void} ErrorDetectionCallback */ -/** @typedef {import('./duckplayer-native').DuckPlayerNativeSettings['selectors']} DuckPlayerNativeSelectors */ +/** + * @import {DuckPlayerNativeSettings} from './duckplayer-native' + * @typedef {"age-restricted" | "sign-in-required" | "no-embed" | "unknown"} YouTubeError + * @typedef {DuckPlayerNativeSettings['selectors']} DuckPlayerNativeSelectors + * @typedef {(error: YouTubeError) => void} ErrorDetectionCallback + */ /** * @typedef {object} ErrorDetectionSettings diff --git a/injected/src/features/duckplayer-native/messages.js b/injected/src/features/duckplayer-native/messages.js index df7a370e68..ec538336be 100644 --- a/injected/src/features/duckplayer-native/messages.js +++ b/injected/src/features/duckplayer-native/messages.js @@ -11,7 +11,7 @@ import * as constants from './constants.js'; */ /** - * @typedef {import("@duckduckgo/messaging").Messaging} Messaging + * @import {Messaging} from '@duckduckgo/messaging' * * A wrapper for all communications. * From 1a3f9de1db396fa83cf1142ab7ed56114cef9b6a Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Thu, 17 Apr 2025 15:04:06 +0100 Subject: [PATCH 23/79] Privacy config update --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 80a842cbaf..0f12f57f7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -251,8 +251,8 @@ }, "node_modules/@duckduckgo/privacy-configuration": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/duckduckgo/privacy-configuration.git#ca6101bb972756a87a8960ffb3029f603052ea9d", - "integrity": "sha512-ev0UMqsvcTcQJP4Uw+r1bJq6P82o3RBE/9H/aS4czdH4JLLNHhDYV5b99HmXMIvR9thTlers8Ye1kxsG2s1T9g==", + "resolved": "git+ssh://git@github.com/duckduckgo/privacy-configuration.git#bc8932cb98a49e7811a328c38a42feed4a75bb55", + "integrity": "sha512-0AaMXFcbDJ8qPfE7FI3WOkpnT6Y3E4UnkGqscYKLlFFMvvnuwprbORnYHnmWSsV/uPVF2FP2FKv6+ZDpULMPkQ==", "dev": true, "license": "Apache 2.0", "dependencies": { From 6b33fd1eb3e8acb80b656a1c03d3098d4a82d524 Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Thu, 17 Apr 2025 15:11:03 +0100 Subject: [PATCH 24/79] Can they co-exist? --- injected/entry-points/integration.js | 1 + 1 file changed, 1 insertion(+) diff --git a/injected/entry-points/integration.js b/injected/entry-points/integration.js index 5a63e47f09..c7aaf755dd 100644 --- a/injected/entry-points/integration.js +++ b/injected/entry-points/integration.js @@ -46,6 +46,7 @@ function generateConfig() { 'cookie', 'webCompat', 'apiManipulation', + 'duckPlayer', 'duckPlayerNative', ], }, From 500e05ad4b48cf55a27b732c9826cbd51f062d7a Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Thu, 17 Apr 2025 15:12:22 +0100 Subject: [PATCH 25/79] Privacy config update --- package-lock.json | 32 ++++++++++++++------------------ package.json | 1 + 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0f12f57f7a..8f11aebf13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "types-generator" ], "dependencies": { + "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration#bc8932cb98a49e7811a328c38a42feed4a75bb55", "immutable-json-patch": "^6.0.1" }, "devDependencies": { @@ -51,6 +52,18 @@ "minimist": "^1.2.8" } }, + "injected/node_modules/@duckduckgo/privacy-configuration": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/duckduckgo/privacy-configuration.git#ca6101bb972756a87a8960ffb3029f603052ea9d", + "integrity": "sha512-ev0UMqsvcTcQJP4Uw+r1bJq6P82o3RBE/9H/aS4czdH4JLLNHhDYV5b99HmXMIvR9thTlers8Ye1kxsG2s1T9g==", + "dev": true, + "license": "Apache 2.0", + "dependencies": { + "eslint-plugin-json": "^4.0.1", + "node-fetch": "^3.3.2", + "tldts": "^6.1.71" + } + }, "messaging": { "name": "@duckduckgo/messaging", "version": "1.0.0", @@ -253,7 +266,6 @@ "version": "1.0.0", "resolved": "git+ssh://git@github.com/duckduckgo/privacy-configuration.git#bc8932cb98a49e7811a328c38a42feed4a75bb55", "integrity": "sha512-0AaMXFcbDJ8qPfE7FI3WOkpnT6Y3E4UnkGqscYKLlFFMvvnuwprbORnYHnmWSsV/uPVF2FP2FKv6+ZDpULMPkQ==", - "dev": true, "license": "Apache 2.0", "dependencies": { "eslint-plugin-json": "^4.0.1", @@ -2016,7 +2028,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -2795,7 +2806,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-json/-/eslint-plugin-json-4.0.1.tgz", "integrity": "sha512-3An5ISV5dq/kHfXdNyY5TUe2ONC3yXFSkLX2gu+W8xAhKhfvrRvkSAeKXCxZqZ0KJLX15ojBuLPyj+UikQMkOA==", - "dev": true, "license": "MIT", "dependencies": { "lodash": "^4.17.21", @@ -3105,7 +3115,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, "funding": [ { "type": "github", @@ -3241,7 +3250,6 @@ "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, "license": "MIT", "dependencies": { "fetch-blob": "^3.1.2" @@ -4329,7 +4337,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "dev": true, "license": "MIT" }, "node_modules/keyv": { @@ -4410,7 +4417,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -4681,7 +4687,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "dev": true, "funding": [ { "type": "github", @@ -4701,7 +4706,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, "license": "MIT", "dependencies": { "data-uri-to-buffer": "^4.0.0", @@ -6251,7 +6255,6 @@ "version": "6.1.71", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.71.tgz", "integrity": "sha512-LQIHmHnuzfZgZWAf2HzL83TIIrD8NhhI0DVxqo9/FdOd4ilec+NTNZOlDZf7EwrTNoutccbsHjvWHYXLAtvxjw==", - "dev": true, "dependencies": { "tldts-core": "^6.1.71" }, @@ -6262,8 +6265,7 @@ "node_modules/tldts-core": { "version": "6.1.71", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.71.tgz", - "integrity": "sha512-LRbChn2YRpic1KxY+ldL1pGXN/oVvKfCVufwfVzEQdFYNo39uF7AJa/WXdo+gYO7PTvdfkCPCed6Hkvz/kR7jg==", - "dev": true + "integrity": "sha512-LRbChn2YRpic1KxY+ldL1pGXN/oVvKfCVufwfVzEQdFYNo39uF7AJa/WXdo+gYO7PTvdfkCPCed6Hkvz/kR7jg==" }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -6623,7 +6625,6 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.2.1.tgz", "integrity": "sha512-xGmv9QIWs2H8obGbWg+sIPI/3/pFgj/5OWBhNzs00BkYQ9UaB2F6JJaGB/2/YOZJ3BvLXQTC4Q7muqU25QgAhA==", - "dev": true, "license": "MIT", "dependencies": { "jsonc-parser": "^3.0.0", @@ -6637,28 +6638,24 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", - "dev": true, "license": "MIT" }, "node_modules/vscode-languageserver-types": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", - "dev": true, "license": "MIT" }, "node_modules/vscode-nls": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz", "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==", - "dev": true, "license": "MIT" }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "dev": true, "license": "MIT" }, "node_modules/web-resource-inliner": { @@ -6717,7 +6714,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" diff --git a/package.json b/package.json index 8b4b35b403..9eef49772c 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "urlpattern-polyfill": "^10.0.0" }, "dependencies": { + "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration#bc8932cb98a49e7811a328c38a42feed4a75bb55", "immutable-json-patch": "^6.0.1" } } From 276e16a5d8884c8689cac800a04d1ea72bfc65a6 Mon Sep 17 00:00:00 2001 From: Marcos Gurgel Date: Thu, 17 Apr 2025 18:24:00 +0100 Subject: [PATCH 26/79] More tests --- .../duckplayer-native.spec.js | 31 +++- .../page-objects/duckplayer-native.js | 31 +++- .../duckplayer-native/pages/player.html | 147 ++++++++++++++++-- injected/src/features.js | 10 +- injected/src/features/duck-player-native.js | 5 +- .../custom-error/custom-error.css | 3 +- .../custom-error/custom-error.js | 31 ++-- .../duckplayer-native/duckplayer-native.js | 3 +- .../features/duckplayer-native/environment.js | 3 + .../duckplayer-native/error-detection.js | 16 +- 10 files changed, 237 insertions(+), 43 deletions(-) diff --git a/injected/integration-test/duckplayer-native.spec.js b/injected/integration-test/duckplayer-native.spec.js index 6415384194..9e6023430c 100644 --- a/injected/integration-test/duckplayer-native.spec.js +++ b/injected/integration-test/duckplayer-native.spec.js @@ -42,5 +42,34 @@ test.describe('Duck Player Native thumbnail overlay', () => { // Then I should see the thumbnail overlay in the page await duckPlayer.didShowThumbnailOverlay(); + await duckPlayer.didShowLogoInOverlay(); }); -}); \ No newline at end of file +}); + +test.describe('Duck Player Native custom error view', () => { + test('Shows age-restricted error', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a YouTube page with an age-restricted error + await duckPlayer.gotoAgeRestrictedErrorPage(); + + // Then I should see the generic error screen + await duckPlayer.didShowGenericError(); + }); + + test('Shows sign-in error', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a YouTube page with an age-restricted error + await duckPlayer.gotoSignInErrorPage(); + + // Then I should see the generic error screen + await duckPlayer.didShowSignInError(); + }); +}); diff --git a/injected/integration-test/page-objects/duckplayer-native.js b/injected/integration-test/page-objects/duckplayer-native.js index 65f39bf6ca..b2ee0d1297 100644 --- a/injected/integration-test/page-objects/duckplayer-native.js +++ b/injected/integration-test/page-objects/duckplayer-native.js @@ -5,6 +5,7 @@ import { ResultsCollector } from './results-collector.js'; /** * @import { PageType} from '../../src/features/duck-player-native.js' + * @typedef {"default" | "incremental-dom" | "age-restricted-error" | "sign-in-error"} PlayerPageVariants */ // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -14,6 +15,7 @@ export class DuckPlayerNative { /** @type {Partial>} */ pages = { YOUTUBE: '/duckplayer-native/pages/player.html', + NOCOOKIE: '/duckplayer-native/pages/player.html', }; /** @@ -48,7 +50,6 @@ export class DuckPlayerNative { /** * @param {object} [params] - * @param {"default" | "incremental-dom"} [params.variant] * @param {string} [params.videoID] */ async gotoYouTubePage(params = {}) { @@ -59,6 +60,14 @@ export class DuckPlayerNative { await this.gotoPage('NOCOOKIE', {}); } + async gotoAgeRestrictedErrorPage() { + await this.gotoPage('NOCOOKIE', { variant: 'age-restricted-error' }); + } + + async gotoSignInErrorPage() { + await this.gotoPage('NOCOOKIE', { variant: 'sign-in-error' }); + } + async gotoSERP() { await this.gotoPage('SERP', {}); } @@ -66,7 +75,7 @@ export class DuckPlayerNative { /** * @param {PageType} pageType * @param {object} [params] - * @param {"default" | "incremental-dom"} [params.variant] + * @param {PlayerPageVariants} [params.variant] * @param {string} [params.videoID] */ async gotoPage(pageType, params = {}) { @@ -206,6 +215,24 @@ export class DuckPlayerNative { async didShowLogoInOverlay() { await this.page.locator('ddg-video-thumbnail-overlay-mobile .logo').waitFor({ state: 'visible', timeout: 1000 }); } + + /* Custom Error assertions */ + + async didShowGenericError() { + await expect(this.page.locator('ddg-video-error')).toMatchAriaSnapshot(` + - heading "YouTube won’t let Duck Player load this video" [level=1] + - paragraph: YouTube doesn’t allow this video to be viewed outside of YouTube. + - paragraph: You can still watch this video on YouTube, but without the added privacy of Duck Player. + `); + } + + async didShowSignInError() { + await expect(this.page.locator('ddg-video-error')).toMatchAriaSnapshot(` + - heading "YouTube won’t let Duck Player load this video" [level=1] + - paragraph: YouTube is blocking this video from loading. If you’re using a VPN, try turning it off and reloading this page. + - paragraph: If this doesn’t work, you can still watch this video on YouTube, but without the added privacy of Duck Player. + `); + } } /** diff --git a/injected/integration-test/test-pages/duckplayer-native/pages/player.html b/injected/integration-test/test-pages/duckplayer-native/pages/player.html index 0ff82fd744..96b7e3d169 100644 --- a/injected/integration-test/test-pages/duckplayer-native/pages/player.html +++ b/injected/integration-test/test-pages/duckplayer-native/pages/player.html @@ -3,7 +3,7 @@ - Duck Player - Player Overlay + Duck Player Native - Player Overlay @@ -152,6 +192,51 @@ + + + +