diff --git a/injected/entry-points/integration.js b/injected/entry-points/integration.js index 7938b7d21c..c7aaf755dd 100644 --- a/injected/entry-points/integration.js +++ b/injected/entry-points/integration.js @@ -47,6 +47,7 @@ function generateConfig() { 'webCompat', 'apiManipulation', 'duckPlayer', + 'duckPlayerNative', ], }, }; diff --git a/injected/integration-test/duckplayer-native.spec.js b/injected/integration-test/duckplayer-native.spec.js new file mode 100644 index 0000000000..2b495c5236 --- /dev/null +++ b/injected/integration-test/duckplayer-native.spec.js @@ -0,0 +1,129 @@ +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(); + + // And an onDuckPlayerScriptsReady event should be called + await duckPlayer.didSendDuckPlayerScriptsReady(); + }); + + test('Responds to onUrlChanged', 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(); + + // And the frontend receives an onUrlChanged event + await duckPlayer.sendURLChanged('NOCOOKIE'); + + // Then an onDuckPlayerScriptsReady event should be fired twice + await duckPlayer.didSendDuckPlayerScriptsReady(2); + }); + + 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(); + }); + + test('Polls timestamp on NoCookie page', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a NoCookie page + await duckPlayer.gotoNoCookiePage(); + + // Then the current timestamp should be polled back to the browser + 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.didShowOverlay(); + await duckPlayer.didShowLogoInOverlay(); + }); + test('Dismisses overlay on click', 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(); + + // And I see the thumbnail overlay in the page + await duckPlayer.didShowOverlay(); + + // And I click on the overlay + await duckPlayer.clickOnOverlay(); + + // Then the overlay should be dismissed + await duckPlayer.didDismissOverlay(); + + // And a didDismissOverlay event should be fired + await duckPlayer.didSendOverlayDismissalMessage(); + }); +}); + +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 new file mode 100644 index 0000000000..071cbd1d3c --- /dev/null +++ b/injected/integration-test/page-objects/duckplayer-native.js @@ -0,0 +1,304 @@ +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/duckplayer-native/messages.js' + * @typedef {"default" | "incremental-dom" | "age-restricted-error" | "sign-in-error"} PlayerPageVariants + */ + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const configFiles = /** @type {const} */ (['native.json']); + +const defaultInitialSetup = { + locale: 'en', +}; + +const featureName = 'duckPlayerNative'; + +export class DuckPlayerNative { + /** @type {Partial>} */ + pages = { + YOUTUBE: '/duckplayer-native/pages/player.html', + NOCOOKIE: '/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: defaultInitialSetup, + 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 {string} [params.videoID] + */ + async gotoYouTubePage(params = {}) { + await this.gotoPage('YOUTUBE', params); + } + + async gotoNoCookiePage() { + 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', {}); + } + + /** + * @param {PageType} pageType + * @param {object} [params] + * @param {PlayerPageVariants} [params.variant] + * @param {string} [params.videoID] + */ + async gotoPage(pageType, params = {}) { + await this.withPageType(pageType); + + const { variant = 'default', videoID = '123' } = params; + const urlParams = new URLSearchParams([ + ['v', videoID], + ['variant', variant], + ]); + + 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 withPageType(pageType) { + const initialSetup = this.collector.mockResponses?.initialSetup || defaultInitialSetup; + + await this.collector.updateMockResponse({ + initialSetup: { pageType, ...initialSetup }, + }); + } + + /** + * @param {boolean} playbackPaused + * @return {Promise} + */ + async withPlaybackPaused(playbackPaused = true) { + const initialSetup = this.collector.mockResponses.initialSetup || defaultInitialSetup; + + await this.collector.updateMockResponse({ + initialSetup: { playbackPaused, ...initialSetup }, + }); + } + + /** + * @param {string} name + * @param {Record} payload + */ + async simulateSubscriptionMessage(name, payload) { + await this.collector.simulateSubscriptionMessage(featureName, 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); + } + + /** + * @param {PageType} pageType + */ + async sendURLChanged(pageType) { + await this.simulateSubscriptionMessage('onUrlChanged', { pageType }); + } + + /* Messaging assertions */ + + async didSendInitialHandshake() { + const messages = await this.collector.waitForMessage('initialSetup'); + expect(messages).toMatchObject([ + { + payload: { + context: this.collector.messagingContextName, + featureName, + method: 'initialSetup', + params: {}, + }, + }, + ]); + } + + async didSendCurrentTimestamp() { + const messages = await this.collector.waitForMessage('onCurrentTimestamp'); + expect(messages).toMatchObject([ + { + payload: { + context: this.collector.messagingContextName, + featureName, + method: 'onCurrentTimestamp', + params: { timestamp: '0' }, + }, + }, + ]); + } + + /* Thumbnail Overlay assertions */ + + async didShowOverlay() { + await this.page.locator('ddg-video-thumbnail-overlay-mobile').waitFor({ state: 'visible', timeout: 1000 }); + } + + async didShowLogoInOverlay() { + await this.page.locator('ddg-video-thumbnail-overlay-mobile .logo').waitFor({ state: 'visible', timeout: 1000 }); + } + + async clickOnOverlay() { + await this.page.locator('ddg-video-thumbnail-overlay-mobile').click(); + } + + async didDismissOverlay() { + await this.page.locator('ddg-video-thumbnail-overlay-mobile').waitFor({ state: 'hidden', timeout: 1000 }); + } + + async didSendOverlayDismissalMessage() { + const messages = await this.collector.waitForMessage('didDismissOverlay'); + expect(messages).toMatchObject([ + { + payload: { + context: this.collector.messagingContextName, + featureName, + method: 'didDismissOverlay', + params: {}, + }, + }, + ]); + } + + /* 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. + `); + } + + /** + * @param {number} numberOfCalls - Number of times the message should be received + */ + async didSendDuckPlayerScriptsReady(numberOfCalls = 1) { + const expectedMessage = { + payload: { + context: this.collector.messagingContextName, + featureName, + method: 'onDuckPlayerScriptsReady', + params: {}, + }, + }; + const expectedMessages = Array(numberOfCalls).fill(expectedMessage); + const actualMessages = await this.collector.waitForMessage('onDuckPlayerScriptsReady'); + + expect(actualMessages).toMatchObject(expectedMessages); + } +} + +/** + * @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/page-objects/results-collector.js b/injected/integration-test/page-objects/results-collector.js index b6911c173f..3cb4768863 100644 --- a/injected/integration-test/page-objects/results-collector.js +++ b/injected/integration-test/page-objects/results-collector.js @@ -244,6 +244,10 @@ export class ResultsCollector { return this.build.name === 'apple-isolated' ? 'contentScopeScriptsIsolated' : 'contentScopeScripts'; } + get mockResponses() { + return this.#mockResponses; + } + /** * @param {string} featureName * @return {import("@duckduckgo/messaging").MessagingContext} diff --git a/injected/integration-test/test-pages/duckplayer-native/config/native.json b/injected/integration-test/test-pages/duckplayer-native/config/native.json new file mode 100644 index 0000000000..8f7e95eedc --- /dev/null +++ b/injected/integration-test/test-pages/duckplayer-native/config/native.json @@ -0,0 +1,20 @@ +{ + "unprotectedTemporary": [], + "features": { + "duckPlayerNative": { + "state": "enabled", + "exceptions": [], + "settings": { + "selectors": { + "errorContainer": "body", + "signInRequiredError": "[href*=\"//support.google.com/youtube/answer/3037019\"]", + "videoElement": "#player video, video", + "videoElementContainer": "#player .html5-video-player", + "youtubeError": ".ytp-error", + "adShowing": ".html5-video-player.ad-showing" + }, + "domains": [] + } + } + } +} diff --git a/injected/integration-test/test-pages/duckplayer-native/pages/player.html b/injected/integration-test/test-pages/duckplayer-native/pages/player.html new file mode 100644 index 0000000000..719f3371bd --- /dev/null +++ b/injected/integration-test/test-pages/duckplayer-native/pages/player.html @@ -0,0 +1,365 @@ + + + + + + Duck Player Native - Player Overlay + + + + + +

[Duck Player]

+ +
+
+ + +
+ +
+ + + +
+
+ +
+ + + + + + + + + + + + + + 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 0000000000..38797b8e29 Binary files /dev/null and b/injected/integration-test/test-pages/duckplayer-native/pages/thumbnail-dark.jpg differ 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 0000000000..f2310b4b4e Binary files /dev/null and b/injected/integration-test/test-pages/duckplayer-native/pages/thumbnail-light.jpg differ diff --git a/injected/package.json b/injected/package.json index 8b092cb008..fcab6bda9a 100644 --- a/injected/package.json +++ b/injected/package.json @@ -31,7 +31,7 @@ }, "devDependencies": { "@canvas/image-data": "^1.0.0", - "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration#ca6101bb972756a87a8960ffb3029f603052ea9d", + "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration#b5c39e2b0592c044d609dfed624db31c3c5bca3b", "@fingerprintjs/fingerprintjs": "^4.6.2", "@types/chrome": "^0.0.315", "@types/jasmine": "^5.1.7", 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.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..896c619b42 --- /dev/null +++ b/injected/src/features/duck-player-native.js @@ -0,0 +1,124 @@ +import ContentFeature from '../content-feature.js'; +import { isBeingFramed } from '../utils.js'; +import { DuckPlayerNativeMessages } from './duckplayer-native/messages.js'; +import { setupDuckPlayerForNoCookie, setupDuckPlayerForSerp, setupDuckPlayerForYouTube } from './duckplayer-native/sub-feature.js'; +import { Environment } from './duckplayer/environment.js'; +import { Logger } from './duckplayer/util.js'; + +/** + * @import {DuckPlayerNativeSubFeature} from './duckplayer-native/sub-feature.js' + * @import {DuckPlayerNativeSettings} from '@duckduckgo/privacy-configuration/schema/features/duckplayer-native.js' + * @import {UrlChangeSettings} from './duckplayer-native/messages.js' + */ + +/** + * @typedef InitialSettings - The initial payload used to communicate render-blocking information + * @property {string} locale - UI locale + * @property {UrlChangeSettings['pageType']} pageType - The type of the current page + * @property {boolean} playbackPaused - Should video start playing or paused + */ + +export class DuckPlayerNativeFeature extends ContentFeature { + /** @type {DuckPlayerNativeSubFeature | null} */ + currentPage; + + async init(args) { + /** + * This feature never operates in a frame + */ + if (isBeingFramed()) return; + + const selectors = this.getFeatureSetting('selectors'); + if (!selectors) { + console.warn('No selectors found. Check remote config. Feature will not be initialized.'); + return; + } + + const locale = args?.locale || args?.language || 'en'; + const env = new Environment({ + debug: this.isDebug, + injectName: import.meta.injectName, + platform: this.platform, + locale, + }); + + const messages = new DuckPlayerNativeMessages(this.messaging, env); + messages.subscribeToURLChange(({ pageType }) => { + const playbackPaused = false; // This can be added to the event data in the future if needed + this.urlChanged(pageType, selectors, playbackPaused, env, messages); + }); + + /** @type {InitialSettings} */ + let initialSetup; + + try { + initialSetup = await messages.initialSetup(); + } catch (e) { + console.warn('Failed to get initial setup', e); + return; + } + + if (initialSetup.pageType) { + const playbackPaused = initialSetup.playbackPaused || false; + this.urlChanged(initialSetup.pageType, selectors, playbackPaused, env, messages); + } + } + + /** + * + * @param {UrlChangeSettings['pageType']} pageType + * @param {DuckPlayerNativeSettings['selectors']} selectors + * @param {boolean} playbackPaused + * @param {Environment} env + * @param {DuckPlayerNativeMessages} messages + */ + urlChanged(pageType, selectors, playbackPaused, env, messages) { + /** @type {DuckPlayerNativeSubFeature | null} */ + let nextPage = null; + + const logger = new Logger({ + id: 'DUCK_PLAYER_NATIVE', + shouldLog: () => env.isTestMode(), + }); + + switch (pageType) { + case 'NOCOOKIE': + nextPage = setupDuckPlayerForNoCookie(selectors, env, messages); + break; + case 'YOUTUBE': + nextPage = setupDuckPlayerForYouTube(selectors, playbackPaused, env, messages); + break; + case 'SERP': + nextPage = setupDuckPlayerForSerp(); + break; + case 'UNKNOWN': + default: + console.warn('No known pageType'); + } + + if (this.currentPage) { + this.currentPage.destroy(); + } + + if (nextPage) { + logger.log('Running init handlers'); + nextPage.onInit(); + this.currentPage = nextPage; + + if (document.readyState === 'loading') { + const loadHandler = () => { + logger.log('Running deferred load handlers'); + nextPage.onLoad(); + messages.notifyScriptIsReady(); + }; + document.addEventListener('DOMContentLoaded', loadHandler, { once: true }); + } else { + logger.log('Running load handlers immediately'); + nextPage.onLoad(); + messages.notifyScriptIsReady(); + } + } + } +} + +export default DuckPlayerNativeFeature; diff --git a/injected/src/features/duck-player.js b/injected/src/features/duck-player.js index 14998fce6c..db6d1cb8fa 100644 --- a/injected/src/features/duck-player.js +++ b/injected/src/features/duck-player.js @@ -35,7 +35,8 @@ import ContentFeature from '../content-feature.js'; import { DuckPlayerOverlayMessages, OpenInDuckPlayerMsg, Pixel } from './duckplayer/overlay-messages.js'; import { isBeingFramed } from '../utils.js'; -import { Environment, initOverlays } from './duckplayer/overlays.js'; +import { initOverlays } from './duckplayer/overlays.js'; +import { Environment } from './duckplayer/environment.js'; /** * @typedef UserValues - A way to communicate user settings diff --git a/injected/src/features/duckplayer-native/constants.js b/injected/src/features/duckplayer-native/constants.js new file mode 100644 index 0000000000..6709bf59b3 --- /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_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'; +export const MSG_NAME_URL_CHANGE = 'onUrlChanged'; +export const MSG_NAME_FEATURE_READY = 'onDuckPlayerFeatureReady'; +export const MSG_NAME_SCRIPTS_READY = 'onDuckPlayerScriptsReady'; +export const MSG_NAME_DISMISS_OVERLAY = 'didDismissOverlay'; diff --git a/injected/src/features/duckplayer-native/custom-error/custom-error.css b/injected/src/features/duckplayer-native/custom-error/custom-error.css new file mode 100644 index 0000000000..61425b69c0 --- /dev/null +++ b/injected/src/features/duckplayer-native/custom-error/custom-error.css @@ -0,0 +1,195 @@ +/* -- VIDEO PLAYER OVERLAY */ +:host { + --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: 1000; + height: 100vh; +} +/* iphone 15 */ +@media screen and (min-width: 390px) { + :host { + --title-size: 20px; + --title-line-height: 25px; + --button-gap: 16px; + --logo-size: 40px; + --logo-gap: 12px; + --title-gap: 16px; + } +} +/* iphone 15 Pro Max */ +@media screen and (min-width: 430px) { + :host { + --title-size: 22px; + --title-gap: 24px; + --button-gap: 20px; + --logo-gap: 16px; + } +} +/* small landscape */ +@media screen and (min-width: 568px) { +} +/* large landscape */ +@media screen and (min-width: 844px) { + :host { + --title-gap: 30px; + --button-gap: 24px; + --logo-size: 48px; + } +} + + +:host * { + font-family: system, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +:root *, :root *:after, :root *:before { + box-sizing: border-box; +} + +.wrapper { + align-items: center; + background-color: var(--background-color); + display: flex; + height: 100%; + justify-content: center; + padding: var(--padding); +} + +.error { + align-items: center; + display: grid; + justify-items: center; +} + +.error.mobile { + border-radius: var(--inner-radius); + overflow: auto; + + /* Prevents automatic text resizing */ + text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + + @media screen and (min-width: 600px) and (min-height: 600px) { + aspect-ratio: 16 / 9; + } +} + +.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; + margin: 0; + max-width: 680px; + padding: 0 40px; + row-gap: 4px; +} + +.mobile .container { + flex-flow: column; + padding: 0 24px; + + @media screen and (min-height: 320px) { + margin: 16px 0; + } + + @media screen and (min-width: 375px) and (min-height: 400px) { + margin: 36px 0; + } +} + +.content { + display: flex; + flex-direction: column; + gap: 4px; + margin: 16px 0; + + @media screen and (min-width: 600px) { + margin: 24px 0; + } +} + + +.icon { + align-self: center; + display: flex; + justify-content: center; + + &::before { + content: ' '; + display: block; + 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; + } + + @media screen and (max-width: 320px) { + display: none; + } + + @media screen and (min-width: 600px) and (min-height: 600px) { + justify-content: start; + + &::before { + 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; + } + } +} + +.heading { + color: #fff; + font-size: 20px; + font-weight: 700; + line-height: calc(24 / 20); + margin: 0; +} + +.messages { + color: #ccc; + font-size: 16px; + line-height: calc(24 / 16); +} + +div.messages { + display: flex; + flex-direction: column; + gap: 24px; + + & p { + margin: 0; + } +} + +p.messages { + margin: 0; +} + +ul.messages { + li { + list-style: disc; + margin-left: 24px; + } +} diff --git a/injected/src/features/duckplayer-native/custom-error/custom-error.js b/injected/src/features/duckplayer-native/custom-error/custom-error.js new file mode 100644 index 0000000000..795ed225ee --- /dev/null +++ b/injected/src/features/duckplayer-native/custom-error/custom-error.js @@ -0,0 +1,124 @@ +import css from './custom-error.css'; +import { Logger } from '../../duckplayer/util.js'; +import { createPolicy, html } from '../../../dom-utils.js'; +import { customElementsDefine, customElementsGet } from '../../../captured-globals.js'; + +/** @import {YouTubeError} from '../error-detection' */ + +/** + * The custom element that we use to present our UI elements + * over the YouTube player + */ +export class CustomError extends HTMLElement { + static CUSTOM_TAG_NAME = 'ddg-video-error'; + + policy = createPolicy(); + /** @type {Logger} */ + logger; + /** @type {boolean} */ + testMode = false; + /** @type {YouTubeError} */ + error; + /** @type {string} */ + title = ''; + /** @type {string[]} */ + messages = []; + + static register() { + if (!customElementsGet(CustomError.CUSTOM_TAG_NAME)) { + customElementsDefine(CustomError.CUSTOM_TAG_NAME, CustomError); + } + } + + connectedCallback() { + this.createMarkupAndStyles(); + } + + createMarkupAndStyles() { + const shadow = this.attachShadow({ mode: this.testMode ? 'open' : 'closed' }); + + const style = document.createElement('style'); + 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; + + this.logger?.log('Created', CustomError.CUSTOM_TAG_NAME, 'with container', container); + } + + /** + * @returns {string} + */ + render() { + if (!this.title || !this.messages) { + console.warn('Missing error title or messages. Please assign before rendering'); + return ''; + } + + const { title, messages } = this; + const messagesHtml = messages.map((message) => html`

${message}

`); + + return html` +
+
+ + +
+

${title}

+
${messagesHtml}
+
+
+
+ `.toString(); + } +} + +/** + * @param {YouTubeError} errorId + * @param {Record} t - Localized strings from environment + */ +function getErrorStrings(errorId, t) { + switch (errorId) { + case 'sign-in-required': + return { + title: t.blockedVideoErrorHeading, + messages: [t.signInRequiredErrorMessage1, t.signInRequiredErrorMessage2], + }; + default: + return { + title: t.blockedVideoErrorHeading, + messages: [t.blockedVideoErrorMessage1, t.blockedVideoErrorMessage2], + }; + } +} + +/** + * + * @param {HTMLElement} targetElement + * @param {YouTubeError} errorId + * @param {import('../../duckplayer/environment.js').Environment} environment + */ +export function showError(targetElement, errorId, environment) { + const { title, messages } = getErrorStrings(errorId, environment.strings('native.json')); + 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; + targetElement.appendChild(customError); + + return () => { + document.querySelector(CustomError.CUSTOM_TAG_NAME)?.remove(); + }; +} 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..ee5d0dbe30 --- /dev/null +++ b/injected/src/features/duckplayer-native/error-detection.js @@ -0,0 +1,188 @@ +import { Logger } from '../duckplayer/util.js'; + +/** + * @import {DuckPlayerNativeSettings} from "@duckduckgo/privacy-configuration/schema/features/duckplayer-native.js" + * @typedef {"age-restricted" | "sign-in-required" | "no-embed" | "unknown"} YouTubeError + * @typedef {DuckPlayerNativeSettings['selectors']} DuckPlayerNativeSelectors + * @typedef {(error: YouTubeError) => void} ErrorDetectionCallback + */ + +/** + * @typedef {object} ErrorDetectionSettings + * @property {DuckPlayerNativeSelectors} selectors + * @property {ErrorDetectionCallback} callback + * @property {boolean} testMode + */ + +/** @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 {Logger} */ + logger; + /** @type {DuckPlayerNativeSelectors} */ + selectors; + /** @type {ErrorDetectionCallback} */ + callback; + /** @type {boolean} */ + testMode; + + /** + * @param {ErrorDetectionSettings} settings + */ + 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; + this.logger = new Logger({ + id: 'ERROR_DETECTION', + shouldLog: () => this.testMode, + }); + } + + /** + * + * @returns {(() => void)|void} + */ + observe() { + const documentBody = document?.body; + if (documentBody) { + // Check if iframe already contains error + if (this.checkForError(documentBody)) { + const error = this.getErrorType(); + this.handleError(error); + return; + } + + // 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(); + }; + } + } + + /** + * + * @param {YouTubeError} errorId + */ + handleError(errorId) { + if (this.callback) { + this.logger.log('Calling error handler for', errorId); + this.callback(errorId); + } else { + this.logger.warn('No error callback found'); + } + } + + /** + * 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)) { + this.logger.log('A node with an error has been added to the document:', node); + const error = this.getErrorType(); + this.handleError(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; + + if (!currentWindow.ytcfg) { + this.logger.warn('ytcfg missing!'); + } else { + this.logger.log('Got ytcfg', currentWindow.ytcfg); + } + + try { + const playerResponseJSON = currentWindow.ytcfg?.get('PLAYER_VARS')?.embedded_player_response; + this.logger.log('Player response', playerResponseJSON); + + playerResponse = JSON.parse(playerResponseJSON); + } catch (e) { + this.logger.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) { + this.logger.log('AGE RESTRICTED ERROR'); + return YOUTUBE_ERRORS.ageRestricted; + } + + // 1.2. Fall back to embed not allowed error + this.logger.log('NO EMBED ERROR'); + return YOUTUBE_ERRORS.noEmbed; + } + } + + // 2. Check for sign-in support link + try { + if (document.querySelector(this.selectors.signInRequiredError)) { + this.logger.log('SIGN-IN ERROR'); + return YOUTUBE_ERRORS.signInRequired; + } + } catch (e) { + this.logger.log('Sign-in required query failed', e); + } + + // 3. Fall back to unknown error + this.logger.log('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 { 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.matches(youtubeError) || !!element.querySelector(youtubeError); + return isError; + } + + return false; + } +} diff --git a/injected/src/features/duckplayer-native/get-current-timestamp.js b/injected/src/features/duckplayer-native/get-current-timestamp.js new file mode 100644 index 0000000000..2a27305367 --- /dev/null +++ b/injected/src/features/duckplayer-native/get-current-timestamp.js @@ -0,0 +1,40 @@ +/** + * @import { DuckPlayerNativeSelectors } from './sub-feature.js'; + */ + +/** + * @param {string} selector - Selector for the video element + * @returns {number} + */ +export function getCurrentTimestamp(selector) { + const video = /** @type {HTMLVideoElement|null} */ (document.querySelector(selector)); + return video?.currentTime || 0; +} + +/** + * Sends the timestamp to the browser at an interval + * + * @param {number} interval - Polling interval + * @param {(timestamp: number) => void} callback - Callback handler for polling event + * @param {DuckPlayerNativeSelectors} selectors - Selectors for the player + */ +export function pollTimestamp(interval = 300, callback, selectors) { + if (!callback || !selectors) { + console.error('Timestamp polling failed. No callback or selectors defined'); + return () => {}; + } + + const isShowingAd = () => { + return selectors.adShowing && !!document.querySelector(selectors.adShowing); + }; + + const timestampPolling = setInterval(() => { + if (isShowingAd()) return; + const timestamp = getCurrentTimestamp(selectors.videoElement); + callback(timestamp); + }, interval); + + return () => { + clearInterval(timestampPolling); + }; +} diff --git a/injected/src/features/duckplayer-native/messages.js b/injected/src/features/duckplayer-native/messages.js new file mode 100644 index 0000000000..2cfecf7f8c --- /dev/null +++ b/injected/src/features/duckplayer-native/messages.js @@ -0,0 +1,114 @@ +import * as constants from './constants.js'; + +/** @import {YouTubeError} from './error-detection.js' */ +/** @import {Environment} from '../duckplayer/environment.js' */ + +/** + * @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 {object} UrlChangeSettings - Settings passed to the onURLChange callback + * @property {PageType} pageType + */ + +/** + * @typedef {'UNKNOWN'|'YOUTUBE'|'NOCOOKIE'|'SERP'} PageType + */ + +/** + * @import {Messaging} from '@duckduckgo/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 + * @param {Environment} environment + * @internal + */ + constructor(messaging, environment) { + /** + * @internal + */ + this.messaging = messaging; + this.environment = environment; + } + + /** + * @returns {Promise} + */ + initialSetup() { + return this.messaging.request(constants.MSG_NAME_INITIAL_SETUP); + } + + /** + * Notifies with current timestamp as a string + * @param {string} timestamp + */ + notifyCurrentTimestamp(timestamp) { + return this.messaging.notify(constants.MSG_NAME_CURRENT_TIMESTAMP, { timestamp }); + } + + /** + * Subscribe to media control events + * @param {(mediaControlSettings: MediaControlSettings) => void} callback + */ + subscribeToMediaControl(callback) { + return this.messaging.subscribe(constants.MSG_NAME_MEDIA_CONTROL, callback); + } + + /** + * Subscribe to mute audio events + * @param {(muteSettings: MuteSettings) => void} callback + */ + subscribeToMuteAudio(callback) { + return this.messaging.subscribe(constants.MSG_NAME_MUTE_AUDIO, callback); + } + + /** + * Subscribe to URL change events + * @param {(urlSettings: UrlChangeSettings) => void} callback + */ + subscribeToURLChange(callback) { + return this.messaging.subscribe(constants.MSG_NAME_URL_CHANGE, callback); + } + + /** + * Notifies browser of YouTube error + * @param {YouTubeError} error + */ + notifyYouTubeError(error) { + this.messaging.notify(constants.MSG_NAME_YOUTUBE_ERROR, { error }); + } + + /** + * Notifies browser that the feature is ready + */ + notifyFeatureIsReady() { + this.messaging.notify(constants.MSG_NAME_FEATURE_READY, {}); + } + + /** + * Notifies browser that scripts are ready to be acalled + */ + notifyScriptIsReady() { + this.messaging.notify(constants.MSG_NAME_SCRIPTS_READY, {}); + } + + /** + * Notifies browser that the overlay was dismissed + */ + notifyOverlayDismissed() { + this.messaging.notify(constants.MSG_NAME_DISMISS_OVERLAY, {}); + } +} diff --git a/injected/src/features/duckplayer-native/mute-audio.js b/injected/src/features/duckplayer-native/mute-audio.js new file mode 100644 index 0000000000..7ad265f160 --- /dev/null +++ b/injected/src/features/duckplayer-native/mute-audio.js @@ -0,0 +1,8 @@ +// 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; + }); +} diff --git a/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.css b/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.css new file mode 100644 index 0000000000..405c319fb7 --- /dev/null +++ b/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.css @@ -0,0 +1,96 @@ +/* -- 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; + --logo-size: 32px; + --logo-gap: 8px; + --gutter: 16px; +} +/* iphone 15 */ +@media screen and (min-width: 390px) { + :host { + --title-size: 20px; + --title-line-height: 25px; + --button-gap: 16px; + --logo-size: 40px; + --logo-gap: 12px; + --title-gap: 16px; + } +} +/* iphone 15 Pro Max */ +@media screen and (min-width: 430px) { + :host { + --title-size: 22px; + --title-gap: 24px; + --button-gap: 20px; + --logo-gap: 16px; + } +} +/* small landscape */ +@media screen and (min-width: 568px) { +} +/* large landscape */ +@media screen and (min-width: 844px) { + :host { + --title-gap: 30px; + --button-gap: 24px; + --logo-size: 48px; + } +} + + +:host * { + font-family: system, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +:root *, :root *:after, :root *:before { + box-sizing: border-box; +} + +.ddg-video-player-overlay { + width: 100%; + height: 100%; + padding-left: var(--gutter); + padding-right: var(--gutter); + + @media screen and (min-width: 568px) { + padding: 0; + } +} + +.bg { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + color: white; + background: rgba(0, 0, 0, 0.6); + background-position: center; + text-align: center; +} + +.logo { + content: " "; + position: absolute; + display: block; + width: 100%; + height: 100%; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: transparent; + background-image: url('data:image/svg+xml,'); + background-size: 90px 64px; + background-position: center center; + background-repeat: no-repeat; +} diff --git a/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.js b/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.js new file mode 100644 index 0000000000..c6e1e63c97 --- /dev/null +++ b/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.js @@ -0,0 +1,115 @@ +import css from './thumbnail-overlay.css'; +import { createPolicy, html } from '../../../dom-utils.js'; +import { customElementsDefine, customElementsGet } from '../../../captured-globals.js'; +import { VideoParams, appendImageAsBackground, Logger } from '../../duckplayer/util.js'; + +/** + * The custom element that we use to present our UI elements + * over the YouTube player + */ +export class DDGVideoThumbnailOverlay extends HTMLElement { + static CUSTOM_TAG_NAME = 'ddg-video-thumbnail-overlay-mobile'; + static OVERLAY_CLICKED = 'overlay-clicked'; + + policy = createPolicy(); + /** @type {Logger} */ + logger; + /** @type {boolean} */ + testMode = false; + /** @type {HTMLElement} */ + container; + /** @type {string} */ + href; + + static register() { + if (!customElementsGet(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME)) { + customElementsDefine(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME, DDGVideoThumbnailOverlay); + } + } + + connectedCallback() { + this.createMarkupAndStyles(); + } + + createMarkupAndStyles() { + const shadow = this.attachShadow({ mode: this.testMode ? 'open' : 'closed' }); + + const style = document.createElement('style'); + 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; + + // Add click event listener to the overlay + const overlay = container.querySelector('.ddg-video-player-overlay'); + if (overlay) { + overlay.addEventListener('click', () => { + this.dispatchEvent(new Event(DDGVideoThumbnailOverlay.OVERLAY_CLICKED)); + }); + } + + this.logger?.log('Created', DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME, 'with container', container); + this.appendThumbnail(); + } + + appendThumbnail() { + const params = VideoParams.forWatchPage(this.href); + const imageUrl = params?.toLargeThumbnailUrl(); + + if (!imageUrl) { + this.logger?.warn('Could not get thumbnail url for video id', params?.id); + return; + } + + if (this.testMode) { + this.logger?.log('Appending thumbnail', imageUrl); + } + appendImageAsBackground(this.container, '.ddg-vpo-bg', imageUrl); + } + + /** + * @returns {string} + */ + render() { + return html` +
+
+ +
+ `.toString(); + } +} + +/** + * + * @param {HTMLElement} targetElement + * @param {import("../../duckplayer/environment").Environment} environment + * @param {() => void} [onClick] Optional callback to be called when the overlay is clicked + */ +export function appendThumbnailOverlay(targetElement, environment, onClick) { + 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(); + + if (onClick) { + overlay.addEventListener(DDGVideoThumbnailOverlay.OVERLAY_CLICKED, onClick); + } + + targetElement.appendChild(overlay); + + return () => { + document.querySelector(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME)?.remove(); + }; +} diff --git a/injected/src/features/duckplayer-native/pause-video.js b/injected/src/features/duckplayer-native/pause-video.js new file mode 100644 index 0000000000..bdb723cf8f --- /dev/null +++ b/injected/src/features/duckplayer-native/pause-video.js @@ -0,0 +1,29 @@ +/** + * + * @param {string} videoSelector + */ +export function stopVideoFromPlaying(videoSelector) { + /** + * Set up the interval - keep calling .pause() to prevent + * the video from playing + */ + const int = setInterval(() => { + const video = /** @type {HTMLVideoElement} */ (document.querySelector(videoSelector)); + if (video?.isConnected) { + video.pause(); + } + }, 10); + + /** + * To clean up, we need to stop the interval + * and then call .play() on the original element, if it's still connected + */ + return () => { + clearInterval(int); + + const video = /** @type {HTMLVideoElement} */ (document.querySelector(videoSelector)); + if (video?.isConnected) { + video.play(); + } + }; +} diff --git a/injected/src/features/duckplayer-native/sub-feature.js b/injected/src/features/duckplayer-native/sub-feature.js new file mode 100644 index 0000000000..746cd12710 --- /dev/null +++ b/injected/src/features/duckplayer-native/sub-feature.js @@ -0,0 +1,70 @@ +import { DuckPlayerNativeYoutube } from './sub-features/duck-player-native-youtube.js'; +import { DuckPlayerNativeNoCookie } from './sub-features/duck-player-native-no-cookie.js'; +import { DuckPlayerNativeSerp } from './sub-features/duck-player-native-serp.js'; + +/** + * @import {DuckPlayerNativeMessages} from './messages.js' + * @import {Environment} from '../duckplayer/environment.js' + * @import {DuckPlayerNativeSettings} from "@duckduckgo/privacy-configuration/schema/features/duckplayer-native.js" + * @typedef {DuckPlayerNativeSettings['selectors']} DuckPlayerNativeSelectors + */ + +/** + * @interface + */ +export class DuckPlayerNativeSubFeature { + /** + * Called immediately when an instance is created + */ + onInit() {} + /** + * Called when the page is in a ready state (could be immediately following 'onInit') + */ + onLoad() {} + /** + * Called when effects should be cleaned up + */ + destroy() {} +} + +/** + * Sets up Duck Player for a YouTube watch page + * + * @param {DuckPlayerNativeSelectors} selectors + * @param {boolean} paused + * @param {Environment} environment + * @param {DuckPlayerNativeMessages} messages + * @return {DuckPlayerNativeSubFeature} + */ +export function setupDuckPlayerForYouTube(selectors, paused, environment, messages) { + return new DuckPlayerNativeYoutube({ + selectors, + environment, + messages, + paused, + }); +} + +/** + * Sets up Duck Player for a video player in the YouTube no-cookie domain + * + * @param {DuckPlayerNativeSelectors} selectors + * @param {Environment} environment + * @param {DuckPlayerNativeMessages} messages + * @return {DuckPlayerNativeSubFeature} + */ +export function setupDuckPlayerForNoCookie(selectors, environment, messages) { + return new DuckPlayerNativeNoCookie({ + selectors, + environment, + messages, + }); +} + +/** + * Sets up Duck Player events for the SERP + * @return {DuckPlayerNativeSubFeature} + */ +export function setupDuckPlayerForSerp() { + return new DuckPlayerNativeSerp(); +} diff --git a/injected/src/features/duckplayer-native/sub-features/duck-player-native-no-cookie.js b/injected/src/features/duckplayer-native/sub-features/duck-player-native-no-cookie.js new file mode 100644 index 0000000000..305c0a588a --- /dev/null +++ b/injected/src/features/duckplayer-native/sub-features/duck-player-native-no-cookie.js @@ -0,0 +1,88 @@ +import { Logger, SideEffects } from '../../duckplayer/util.js'; +import { pollTimestamp } from '../get-current-timestamp.js'; +import { showError } from '../custom-error/custom-error.js'; +import { ErrorDetection } from '../error-detection.js'; + +/** + * @import {DuckPlayerNativeMessages} from '../messages.js' + * @import {Environment} from '../../duckplayer/environment.js' + * @import {ErrorDetectionSettings} from '../error-detection.js' + * @import {DuckPlayerNativeSelectors} from '../sub-feature.js' + */ +/** + * @import {DuckPlayerNativeSubFeature} from "../sub-feature.js" + * @implements {DuckPlayerNativeSubFeature} + */ +export class DuckPlayerNativeNoCookie { + /** + * @param {object} options + * @param {Environment} options.environment + * @param {DuckPlayerNativeMessages} options.messages + * @param {DuckPlayerNativeSelectors} options.selectors + */ + constructor({ environment, messages, selectors }) { + this.environment = environment; + this.selectors = selectors; + this.messages = messages; + this.sideEffects = new SideEffects({ + debug: environment.isTestMode(), + }); + this.logger = new Logger({ + id: 'DUCK_PLAYER_NATIVE', + shouldLog: () => this.environment.isTestMode(), + }); + } + + onInit() {} + + onLoad() { + this.sideEffects.add('started polling current timestamp', () => { + const handler = (timestamp) => { + this.messages.notifyCurrentTimestamp(timestamp.toFixed(0)); + }; + + return pollTimestamp(300, handler, this.selectors); + }); + + this.logger.log('Setting up error detection'); + const errorContainer = this.selectors?.errorContainer; + const signInRequiredError = this.selectors?.signInRequiredError; + if (!errorContainer || !signInRequiredError) { + this.logger.warn('Missing error selectors in configuration'); + return; + } + + /** @type {(errorId: import('../error-detection.js').YouTubeError) => void} */ + const errorHandler = (errorId) => { + this.logger.log('Received error', errorId); + + // Notify the browser of the error + this.messages.notifyYouTubeError(errorId); + + const targetElement = document.querySelector(errorContainer); + if (targetElement) { + showError(/** @type {HTMLElement} */ (targetElement), errorId, this.environment); + } + }; + + /** @type {ErrorDetectionSettings} */ + const errorDetectionSettings = { + selectors: this.selectors, + testMode: this.environment.isTestMode(), + callback: errorHandler, + }; + + this.sideEffects.add('setting up error detection', () => { + const errorDetection = new ErrorDetection(errorDetectionSettings); + const destroy = errorDetection.observe(); + + return () => { + if (destroy) destroy(); + }; + }); + } + + destroy() { + this.sideEffects.destroy(); + } +} diff --git a/injected/src/features/duckplayer-native/sub-features/duck-player-native-serp.js b/injected/src/features/duckplayer-native/sub-features/duck-player-native-serp.js new file mode 100644 index 0000000000..225901e92a --- /dev/null +++ b/injected/src/features/duckplayer-native/sub-features/duck-player-native-serp.js @@ -0,0 +1,24 @@ +/** + * @import {DuckPlayerNativeSubFeature} from "../sub-feature.js" + * @implements {DuckPlayerNativeSubFeature} + */ +export class DuckPlayerNativeSerp { + onLoad() { + window.dispatchEvent( + new CustomEvent('ddg-serp-yt-response', { + detail: { + kind: 'initialSetup', + data: { + privatePlayerMode: { enabled: {} }, + overlayInteracted: false, + }, + }, + composed: true, + bubbles: true, + }), + ); + } + + onInit() {} + destroy() {} +} diff --git a/injected/src/features/duckplayer-native/sub-features/duck-player-native-youtube.js b/injected/src/features/duckplayer-native/sub-features/duck-player-native-youtube.js new file mode 100644 index 0000000000..811cb9415f --- /dev/null +++ b/injected/src/features/duckplayer-native/sub-features/duck-player-native-youtube.js @@ -0,0 +1,103 @@ +import { Logger, SideEffects } from '../../duckplayer/util.js'; +import { muteAudio } from '../mute-audio.js'; +import { pollTimestamp } from '../get-current-timestamp.js'; +import { stopVideoFromPlaying } from '../pause-video.js'; +import { appendThumbnailOverlay as showThumbnailOverlay } from '../overlays/thumbnail-overlay.js'; + +/** + * @import {DuckPlayerNativeMessages} from '../messages.js' + * @import {Environment} from '../../duckplayer/environment.js' + * @import {DuckPlayerNativeSelectors} from '../sub-feature.js' + */ + +/** + * @import {DuckPlayerNativeSubFeature} from "../sub-feature.js" + * @implements {DuckPlayerNativeSubFeature} + */ +export class DuckPlayerNativeYoutube { + /** + * @param {object} options + * @param {DuckPlayerNativeSelectors} options.selectors + * @param {Environment} options.environment + * @param {DuckPlayerNativeMessages} options.messages + * @param {boolean} options.paused + */ + constructor({ selectors, environment, messages, paused }) { + this.environment = environment; + this.messages = messages; + this.selectors = selectors; + this.paused = paused; + this.sideEffects = new SideEffects({ + debug: environment.isTestMode(), + }); + this.logger = new Logger({ + id: 'DUCK_PLAYER_NATIVE', + shouldLog: () => this.environment.isTestMode(), + }); + } + + onInit() { + this.sideEffects.add('subscribe to media control', () => { + return this.messages.subscribeToMediaControl(({ pause }) => { + this.mediaControlHandler(pause); + }); + }); + + this.sideEffects.add('subscribing to mute audio', () => { + return this.messages.subscribeToMuteAudio(({ mute }) => { + this.logger.log('Running mute audio handler. Mute:', mute); + muteAudio(mute); + }); + }); + } + + onLoad() { + this.sideEffects.add('started polling current timestamp', () => { + const handler = (timestamp) => { + this.messages.notifyCurrentTimestamp(timestamp.toFixed(0)); + }; + + return pollTimestamp(300, handler, this.selectors); + }); + + if (this.paused) { + this.mediaControlHandler(!!this.paused); + } + } + + /** + * @param {boolean} pause + */ + mediaControlHandler(pause) { + this.logger.log('Running media control handler. Pause:', pause); + + const videoElement = this.selectors?.videoElement; + const videoElementContainer = this.selectors?.videoElementContainer; + if (!videoElementContainer || !videoElement) { + this.logger.warn('Missing media control selectors in config'); + return; + } + + const targetElement = document.querySelector(videoElementContainer); + if (targetElement) { + if (pause) { + this.sideEffects.add('stopping video from playing', () => stopVideoFromPlaying(videoElement)); + this.sideEffects.add('appending thumbnail', () => { + const clickHandler = () => { + this.messages.notifyOverlayDismissed(); + this.sideEffects.destroy('stopping video from playing'); + this.sideEffects.destroy('appending thumbnail'); + }; + return showThumbnailOverlay(/** @type {HTMLElement} */ (targetElement), this.environment, clickHandler); + }); + } else { + this.sideEffects.destroy('stopping video from playing'); + this.sideEffects.destroy('appending thumbnail'); + } + } + } + + destroy() { + this.sideEffects.destroy(); + } +} diff --git a/injected/src/features/duckplayer/components/ddg-video-overlay.js b/injected/src/features/duckplayer/components/ddg-video-overlay.js index 9712b637fb..8b797d5dbc 100644 --- a/injected/src/features/duckplayer/components/ddg-video-overlay.js +++ b/injected/src/features/duckplayer/components/ddg-video-overlay.js @@ -15,7 +15,7 @@ export class DDGVideoOverlay extends HTMLElement { static CUSTOM_TAG_NAME = 'ddg-video-overlay'; /** * @param {object} options - * @param {import("../overlays.js").Environment} options.environment + * @param {import("../environment.js").Environment} options.environment * @param {import("../util").VideoParams} options.params * @param {import("../../duck-player.js").UISettings} options.ui * @param {VideoOverlay} options.manager diff --git a/injected/src/features/duckplayer/environment.js b/injected/src/features/duckplayer/environment.js new file mode 100644 index 0000000000..ac9942ae07 --- /dev/null +++ b/injected/src/features/duckplayer/environment.js @@ -0,0 +1,115 @@ +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; + } + + /** + * @param {"overlays.json" | "native.json"} named + * @returns {Record} + */ + strings(named) { + const matched = this._strings[this.locale]; + if (matched) return matched[named]; + return this._strings.en[named]; + } + + /** + * 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; + } + + getLargeThumbnailSrc(videoId) { + const url = new URL(`/vi/${videoId}/maxresdefault.jpg`, 'https://i.ytimg.com'); + return url.href; + } + + setHref(href) { + window.location.href = href; + } + + hasOneTimeOverride() { + try { + // #ddg-play is a hard requirement, regardless of referrer + if (window.location.hash !== '#ddg-play') return false; + + // double-check that we have something that might be a parseable URL + if (typeof document.referrer !== 'string') return false; + if (document.referrer.length === 0) return false; // can be empty! + + const { hostname } = new URL(document.referrer); + const isAllowed = this.allowedProxyOrigins.includes(hostname); + return isAllowed; + } catch (e) { + console.error(e); + } + return false; + } + + isIntegrationMode() { + return this.debug === true && this.injectName === 'integration'; + } + + isTestMode() { + return this.debug === true; + } + + get opensVideoOverlayLinksViaMessage() { + return this.platform.name !== 'windows'; + } + + /** + * @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/overlay-messages.js b/injected/src/features/duckplayer/overlay-messages.js index 2c607ffb5d..3e61e5f471 100644 --- a/injected/src/features/duckplayer/overlay-messages.js +++ b/injected/src/features/duckplayer/overlay-messages.js @@ -12,7 +12,7 @@ import * as constants from './constants.js'; export class DuckPlayerOverlayMessages { /** * @param {Messaging} messaging - * @param {import('./overlays.js').Environment} environment + * @param {import('./environment.js').Environment} environment * @internal */ constructor(messaging, environment) { diff --git a/injected/src/features/duckplayer/overlays.js b/injected/src/features/duckplayer/overlays.js index 4fc4909ffb..9de25b5442 100644 --- a/injected/src/features/duckplayer/overlays.js +++ b/injected/src/features/duckplayer/overlays.js @@ -2,7 +2,6 @@ import { DomState } from './util.js'; import { ClickInterception, Thumbnails } from './thumbnails.js'; import { VideoOverlay } from './video-overlay.js'; import { registerCustomElements } from './components/index.js'; -import strings from '../../../../build/locales/duckplayer-locales.js'; /** * @typedef {object} OverlayOptions @@ -10,12 +9,12 @@ import strings from '../../../../build/locales/duckplayer-locales.js'; * @property {import("../duck-player.js").OverlaysFeatureSettings} settings * @property {import("../duck-player.js").DuckPlayerOverlayMessages} messages * @property {import("../duck-player.js").UISettings} ui - * @property {Environment} environment + * @property {import("./environment.js").Environment} environment */ /** * @param {import("../duck-player.js").OverlaysFeatureSettings} settings - methods to read environment-sensitive things like the current URL etc - * @param {import("./overlays.js").Environment} environment - methods to read environment-sensitive things like the current URL etc + * @param {import("./environment.js").Environment} environment - methods to read environment-sensitive things like the current URL etc * @param {import("./overlay-messages.js").DuckPlayerOverlayMessages} messages - methods to communicate with a native backend */ export async function initOverlays(settings, environment, messages) { @@ -27,12 +26,12 @@ export async function initOverlays(settings, environment, messages) { try { initialSetup = await messages.initialSetup(); } catch (e) { - console.error(e); + console.warn(e); return; } if (!initialSetup) { - console.error('cannot continue without user settings'); + console.warn('cannot continue without user settings'); return; } @@ -167,113 +166,3 @@ function videoOverlaysFeatureFromSettings({ userValues, settings, messages, envi return new VideoOverlay({ userValues, settings, environment, messages, ui }); } - -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['overlays.json']; - return this._strings.en['overlays.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; - } - - getLargeThumbnailSrc(videoId) { - const url = new URL(`/vi/${videoId}/maxresdefault.jpg`, 'https://i.ytimg.com'); - return url.href; - } - - setHref(href) { - window.location.href = href; - } - - hasOneTimeOverride() { - try { - // #ddg-play is a hard requirement, regardless of referrer - if (window.location.hash !== '#ddg-play') return false; - - // double-check that we have something that might be a parseable URL - if (typeof document.referrer !== 'string') return false; - if (document.referrer.length === 0) return false; // can be empty! - - const { hostname } = new URL(document.referrer); - const isAllowed = this.allowedProxyOrigins.includes(hostname); - return isAllowed; - } catch (e) { - console.error(e); - } - return false; - } - - isIntegrationMode() { - return this.debug === true && this.injectName === 'integration'; - } - - isTestMode() { - return this.debug === true; - } - - get opensVideoOverlayLinksViaMessage() { - return this.platform.name !== 'windows'; - } - - /** - * @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/thumbnails.js b/injected/src/features/duckplayer/thumbnails.js index aad5b64f80..4192408090 100644 --- a/injected/src/features/duckplayer/thumbnails.js +++ b/injected/src/features/duckplayer/thumbnails.js @@ -54,13 +54,13 @@ import { SideEffects, VideoParams } from './util.js'; import { IconOverlay } from './icon-overlay.js'; -import { Environment } from './overlays.js'; +import { Environment } from './environment.js'; import { OpenInDuckPlayerMsg, Pixel } from './overlay-messages.js'; /** * @typedef ThumbnailParams * @property {import("../duck-player.js").OverlaysFeatureSettings} settings - * @property {import("./overlays.js").Environment} environment + * @property {import("./environment.js").Environment} environment * @property {import("../duck-player.js").DuckPlayerOverlayMessages} messages */ diff --git a/injected/src/features/duckplayer/util.js b/injected/src/features/duckplayer/util.js index d39d44615d..86cae2f7e8 100644 --- a/injected/src/features/duckplayer/util.js +++ b/injected/src/features/duckplayer/util.js @@ -1,17 +1,4 @@ /* 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 @@ -116,9 +103,11 @@ export class SideEffects { /** * Remove elements, event listeners etc + * @param {string} [name] */ - destroy() { - for (const cleanup of this._cleanups) { + destroy(name) { + const cleanups = name ? this._cleanups.filter((c) => c.name === name) : this._cleanups; + for (const cleanup of cleanups) { if (typeof cleanup.fn === 'function') { try { if (this.debug) { @@ -132,7 +121,11 @@ export class SideEffects { throw new Error('invalid cleanup'); } } - this._cleanups = []; + if (name) { + this._cleanups = this._cleanups.filter((c) => c.name !== name); + } else { + this._cleanups = []; + } } } @@ -179,6 +172,16 @@ export class VideoParams { 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 * @@ -282,3 +285,45 @@ export class DomState { this.loadedCallbacks.push(loadedCallback); } } + +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); + } + } +} diff --git a/injected/src/features/duckplayer/video-overlay.js b/injected/src/features/duckplayer/video-overlay.js index 28936a7851..129de68800 100644 --- a/injected/src/features/duckplayer/video-overlay.js +++ b/injected/src/features/duckplayer/video-overlay.js @@ -51,7 +51,7 @@ export class VideoOverlay { * @param {object} options * @param {import("../duck-player.js").UserValues} options.userValues * @param {import("../duck-player.js").OverlaysFeatureSettings} options.settings - * @param {import("./overlays.js").Environment} options.environment + * @param {import("./environment.js").Environment} options.environment * @param {import("./overlay-messages.js").DuckPlayerOverlayMessages} options.messages * @param {import("../duck-player.js").UISettings} options.ui */ @@ -251,7 +251,7 @@ export class VideoOverlay { this.sideEffects.add(`appending ${DDGVideoOverlayMobile.CUSTOM_TAG_NAME} to the page`, () => { const elem = /** @type {DDGVideoOverlayMobile} */ (document.createElement(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)); elem.testMode = this.environment.isTestMode(); - elem.text = mobileStrings(this.environment.strings); + elem.text = mobileStrings(this.environment.strings('overlays.json')); elem.addEventListener(DDGVideoOverlayMobile.OPEN_INFO, () => this.messages.openInfo()); elem.addEventListener(DDGVideoOverlayMobile.OPT_OUT, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { return this.mobileOptOut(e.detail.remember).catch(console.error); @@ -286,7 +286,7 @@ export class VideoOverlay { const drawer = /** @type {DDGVideoDrawerMobile} */ (document.createElement(DDGVideoDrawerMobile.CUSTOM_TAG_NAME)); drawer.testMode = this.environment.isTestMode(); - drawer.text = mobileStrings(this.environment.strings); + drawer.text = mobileStrings(this.environment.strings('overlays.json')); drawer.addEventListener(DDGVideoDrawerMobile.OPEN_INFO, () => this.messages.openInfo()); drawer.addEventListener(DDGVideoDrawerMobile.OPT_OUT, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { return this.mobileOptOut(e.detail.remember).catch(console.error); 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" + } +} diff --git a/injected/unit-test/features.js b/injected/unit-test/features.js index 85b9cf6829..9d2183c129 100644 --- a/injected/unit-test/features.js +++ b/injected/unit-test/features.js @@ -5,6 +5,7 @@ describe('Features definition', () => { // ensuring this order doesn't change, as it recently caused breakage expect(platformSupport.apple).toEqual([ 'webCompat', + 'duckPlayerNative', 'fingerprintingAudio', 'fingerprintingBattery', 'fingerprintingCanvas', diff --git a/injected/unit-test/verify-artifacts.js b/injected/unit-test/verify-artifacts.js index 7b51dd0705..196968b754 100644 --- a/injected/unit-test/verify-artifacts.js +++ b/injected/unit-test/verify-artifacts.js @@ -8,7 +8,7 @@ console.log(ROOT); const BUILD = join(ROOT, 'build'); const APPLE_BUILD = join(ROOT, 'Sources/ContentScopeScripts/dist'); console.log(APPLE_BUILD); -let CSS_OUTPUT_SIZE = 760_000; +let CSS_OUTPUT_SIZE = 770_000; if (process.platform === 'win32') { CSS_OUTPUT_SIZE = CSS_OUTPUT_SIZE * 1.1; // 10% larger for Windows due to line endings } diff --git a/package-lock.json b/package-lock.json index 80a842cbaf..09e06086d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@canvas/image-data": "^1.0.0", - "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration#ca6101bb972756a87a8960ffb3029f603052ea9d", + "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration#b5c39e2b0592c044d609dfed624db31c3c5bca3b", "@fingerprintjs/fingerprintjs": "^4.6.2", "@types/chrome": "^0.0.315", "@types/jasmine": "^5.1.7", @@ -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#b5c39e2b0592c044d609dfed624db31c3c5bca3b", + "integrity": "sha512-lbB7ix8XDx2gVBz6Ey8GAJCe1AoKtXfknSIMsFL2Iu6yJR6CluQs1685NZsLTGU1DU6YocVEDffHNe+0YNIfcw==", "dev": true, "license": "Apache 2.0", "dependencies": {