From 609dd5781a6bac81c1f8a7da1050a6a8908a6a51 Mon Sep 17 00:00:00 2001 From: Shane Osbourne Date: Mon, 9 Sep 2024 12:18:37 +0100 Subject: [PATCH] DuckPlayer mobile - overlay support --- .eslintignore | 2 +- .gitignore | 4 + .../playwright/duckplayer-mobile.spec.js | 109 ++++++ .../page-objects/duckplayer-overlays.js | 105 ++++- integration-test/playwright/type-helpers.mjs | 1 + .../test-pages/duckplayer/pages/player.html | 75 +++- .../test-pages/duckplayer/scripts/test.mjs | 24 ++ playwright.config.js | 14 + scripts/inject.js | 3 +- src/features.js | 3 +- src/features/duck-player.js | 8 +- src/features/duckplayer/assets/info.svg | 5 + .../assets/mobile-video-overlay.css | 360 ++++++++++++++++++ .../components/ddg-video-overlay-mobile.js | 133 +++++++ src/features/duckplayer/components/index.js | 4 + src/features/duckplayer/constants.js | 1 + src/features/duckplayer/icon-overlay.js | 28 +- src/features/duckplayer/overlay-messages.js | 15 +- src/features/duckplayer/overlays.js | 40 +- src/features/duckplayer/text.js | 14 + src/features/duckplayer/util.js | 16 +- src/features/duckplayer/video-overlay.js | 117 +++++- tsconfig.json | 1 + 23 files changed, 1032 insertions(+), 50 deletions(-) create mode 100644 integration-test/playwright/duckplayer-mobile.spec.js create mode 100644 integration-test/test-pages/duckplayer/scripts/test.mjs create mode 100644 src/features/duckplayer/assets/info.svg create mode 100644 src/features/duckplayer/assets/mobile-video-overlay.css create mode 100644 src/features/duckplayer/components/ddg-video-overlay-mobile.js diff --git a/.eslintignore b/.eslintignore index a765d8a507..cf8b9d6b85 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,7 +3,7 @@ docs/ /lib Sources/ContentScopeScripts/dist/ integration-test/extension/contentScope.js -integration-test/pages/build +integration-test/test-pages/duckplayer/scripts/dist packages/special-pages/pages/**/public script-overload-snapshots/ packages/special-pages/playwright-report/ diff --git a/.gitignore b/.gitignore index 354e6c5baa..921f376001 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,11 @@ node_modules/ .build/ build/ docs/ +/screens test-results/ playwright-report/ Sources/ContentScopeScripts/dist/ test-results + +# Local Netlify folder +.netlify diff --git a/integration-test/playwright/duckplayer-mobile.spec.js b/integration-test/playwright/duckplayer-mobile.spec.js new file mode 100644 index 0000000000..707694d5b7 --- /dev/null +++ b/integration-test/playwright/duckplayer-mobile.spec.js @@ -0,0 +1,109 @@ +import { test } from '@playwright/test' +import { DuckplayerOverlays } from './page-objects/duckplayer-overlays.js' + +test.describe('Video Player overlays', () => { + test('Selecting \'watch here\' on mobile', async ({ page }, workerInfo) => { + const overlays = DuckplayerOverlays.create(page, workerInfo) + + // Given overlays feature is enabled + await overlays.withRemoteConfig() + + // And my setting is 'always ask' + await overlays.userSettingIs('always ask') + await overlays.gotoPlayerPage() + + // watch here = overlays removed + await overlays.mobile.choosesWatchHere() + await overlays.mobile.overlayIsRemoved() + await overlays.pixels.sendsPixels([ + { pixelName: 'overlay', params: {} }, + { pixelName: 'play.do_not_use', params: { remember: '0' } } + ]) + }) + test('Selecting \'watch here\' on mobile + remember', async ({ page }, workerInfo) => { + const overlays = DuckplayerOverlays.create(page, workerInfo) + + // Given overlays feature is enabled + await overlays.withRemoteConfig() + + // And my setting is 'always ask' + await overlays.userSettingIs('always ask') + await overlays.gotoPlayerPage() + + // watch here = overlays removed + await overlays.mobile.selectsRemember() + await overlays.mobile.choosesWatchHere() + await overlays.mobile.overlayIsRemoved() + await overlays.pixels.sendsPixels([ + { pixelName: 'overlay', params: {} }, + { pixelName: 'play.do_not_use', params: { remember: '1' } } + ]) + await overlays.userSettingWasUpdatedTo('disabled') + }) + test('Selecting \'watch in duckplayer\' on mobile', async ({ page }, workerInfo) => { + const overlays = DuckplayerOverlays.create(page, workerInfo) + + // Given overlays feature is enabled + await overlays.withRemoteConfig() + + // And my setting is 'always ask' + await overlays.userSettingIs('always ask') + await overlays.gotoPlayerPage() + + await overlays.mobile.choosesDuckPlayer() + await overlays.pixels.sendsPixels([ + { pixelName: 'overlay', params: {} }, + { pixelName: 'play.use', params: { remember: '0' } } + ]) + await overlays.userSettingWasUpdatedTo('always ask') + }) + test('Selecting \'watch in duckplayer\' on mobile + remember', async ({ page }, workerInfo) => { + const overlays = DuckplayerOverlays.create(page, workerInfo) + + // Given overlays feature is enabled + await overlays.withRemoteConfig() + + // And my setting is 'always ask' + await overlays.userSettingIs('always ask') + await overlays.gotoPlayerPage() + + await overlays.mobile.selectsRemember() + await overlays.mobile.choosesDuckPlayer() + await overlays.pixels.sendsPixels([ + { pixelName: 'overlay', params: {} }, + { pixelName: 'play.use', params: { remember: '1' } } + ]) + await overlays.userSettingWasUpdatedTo('enabled') + }) + test('opens info', async ({ page }, workerInfo) => { + const overlays = DuckplayerOverlays.create(page, workerInfo) + + // Given overlays feature is enabled + await overlays.withRemoteConfig() + + // And my setting is 'always ask' + await overlays.userSettingIs('always ask') + await overlays.gotoPlayerPage() + await overlays.mobile.opensInfo() + }) +}) + +/** + * Use this test in `--headed` mode to cycle through every language + */ +test.describe.skip('Translated Overlays', () => { + const items = ['bg', 'cs', 'da', 'de', 'el', 'en', 'es', 'et', 'fi', 'fr', 'hr', 'hu', 'it', 'lt', 'lv', 'nb', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'sl', 'sv', 'tr'] + // const items = ['en'] + for (const locale of items) { + test(`testing UI ${locale}`, async ({ page }, workerInfo) => { + // console.log(workerInfo.project.use.viewport.height) + // console.log(workerInfo.project.use.viewport.width) + const overlays = DuckplayerOverlays.create(page, workerInfo) + await overlays.withRemoteConfig({ locale }) + await overlays.userSettingIs('always ask') + await overlays.gotoPlayerPage() + await page.locator('ddg-video-overlay-mobile').nth(0).waitFor() + await page.locator('.html5-video-player').screenshot({ path: `screens/se-2/${locale}.png` }) + }) + } +}) diff --git a/integration-test/playwright/page-objects/duckplayer-overlays.js b/integration-test/playwright/page-objects/duckplayer-overlays.js index f3e908b0ae..bc06885952 100644 --- a/integration-test/playwright/page-objects/duckplayer-overlays.js +++ b/integration-test/playwright/page-objects/duckplayer-overlays.js @@ -1,5 +1,6 @@ import { readFileSync } from 'fs' import { + mockAndroidMessaging, mockResponses, mockWebkitMessaging, mockWindowsMessaging, readOutgoingMessages, simulateSubscriptionMessage, waitForCallCount, wrapWebkitScripts, @@ -68,6 +69,8 @@ export class DuckplayerOverlays { playerPage = '/duckplayer/pages/player.html' videoAltSelectors = '/duckplayer/pages/video-alt-selectors.html' serpProxyPage = '/duckplayer/pages/serp-proxy.html' + mobile = new DuckplayerOverlaysMobile(this) + pixels = new DuckplayerOverlayPixels(this) /** * @param {import("@playwright/test").Page} page * @param {import("../type-helpers.mjs").Build} build @@ -110,11 +113,6 @@ export class DuckplayerOverlays { // await this.dismissCookies() } - async gotoYoutubeSearchPage () { - await this.page.goto('https://www.youtube.com/results?search_query=taylor+swift') - // await this.dismissCookies() - } - async gotoYoutubeSearchPageForMovie () { await this.page.goto('https://www.youtube.com/results?search_query=snatch') // await this.dismissCookies() @@ -241,18 +239,22 @@ export class DuckplayerOverlays { /** * @param {object} [params] * @param {configFiles[number]} [params.json="overlays"] - default is settings for localhost + * @param {string} [params.locale] - optional locale */ async withRemoteConfig (params = {}) { - const { json = 'overlays.json' } = params + const { + json = 'overlays.json', + locale = 'en' + } = params - await this.setup({ config: loadConfig(json) }) + await this.setup({ config: loadConfig(json), locale }) } async serpProxyEnabled () { const config = loadConfig('overlays.json') const domains = config.features.duckPlayer.settings.domains[0].patchSettings config.features.duckPlayer.settings.domains[0].patchSettings = domains.filter(x => x.path === '/overlays/serpProxy/state') - await this.setup({ config }) + await this.setup({ config, locale: 'en' }) } async videoOverlayDoesntShow () { @@ -267,7 +269,8 @@ export class DuckplayerOverlays { await this.page.addInitScript(mockResponses, { responses: { initialSetup: { - userValues: userValues[setting] + userValues: userValues[setting], + ui: {} } } }) @@ -280,7 +283,8 @@ export class DuckplayerOverlays { */ async initialSetupIs (userValueSetting, uiSetting) { const initialSetupResponse = { - userValues: userValues[userValueSetting] + userValues: userValues[userValueSetting], + ui: {} } if (uiSetting && uiSettings[uiSetting]) { @@ -331,7 +335,7 @@ export class DuckplayerOverlays { const config = loadConfig('overlays.json') // remove all domains from 'overlays', this disables the feature config.features.duckPlayer.settings.domains = [] - await this.setup({ config }) + await this.setup({ config, locale: 'en' }) } async hoverAThumbnail () { @@ -468,10 +472,11 @@ export class DuckplayerOverlays { * * @param {object} params * @param {Record} params.config + * @param {string} params.locale * @return {Promise} */ async setup (params) { - const { config } = params + const { config, locale } = params await this.build.switch({ windows: async () => { @@ -483,13 +488,17 @@ export class DuckplayerOverlays { }, 'apple-isolated': async () => { // noop + }, + android: async () => { + // noop } }) // read the built file from disk and do replacements const wrapFn = this.build.switch({ 'apple-isolated': () => wrapWebkitScripts, - windows: () => wrapWindowsScripts + windows: () => wrapWindowsScripts, + android: () => wrapWebkitScripts }) const injectedJS = wrapFn(this.build.artifact, { @@ -497,13 +506,20 @@ export class DuckplayerOverlays { $USER_UNPROTECTED_DOMAINS$: [], $USER_PREFERENCES$: { platform: { name: this.platform.name }, - debug: true + debug: true, + + // additional android keys + messageCallback: 'messageCallback', + messageSecret: 'duckduckgo-android-messaging-secret', + javascriptInterface: this.messagingContext, + locale } }) const mockMessaging = this.build.switch({ windows: () => mockWindowsMessaging, - 'apple-isolated': () => mockWebkitMessaging + 'apple-isolated': () => mockWebkitMessaging, + android: () => mockAndroidMessaging }) await this.page.addInitScript(mockMessaging, { @@ -517,7 +533,8 @@ export class DuckplayerOverlays { userValues: { privatePlayerMode: { alwaysAsk: {} }, overlayInteracted: false - } + }, + ui: {} }, getUserValues: { privatePlayerMode: { alwaysAsk: {} }, @@ -537,7 +554,6 @@ export class DuckplayerOverlays { /** * @param {string} method - * @return {Promise} */ async waitForMessage (method) { await this.page.waitForFunction(waitForCallCount, { @@ -642,6 +658,61 @@ export class DuckplayerOverlays { } } +class DuckplayerOverlaysMobile { + /** + * @param {DuckplayerOverlays} overlays + */ + constructor (overlays) { + this.overlays = overlays + } + + async choosesWatchHere () { + const { page } = this.overlays + await page.getByRole('button', { name: 'No Thanks' }).click() + } + + async choosesDuckPlayer () { + const { page } = this.overlays + await page.getByRole('link', { name: 'Turn On Duck Player' }).click() + } + + async selectsRemember () { + const { page } = this.overlays + await page.getByRole('switch').click() + } + + async overlayIsRemoved () { + const { page } = this.overlays + expect(await page.locator('ddg-video-overlay-mobile').count()).toBe(0) + } + + async opensInfo () { + const { page } = this.overlays + await page.getByLabel('Open Information Modal').click() + const messages = await this.overlays.waitForMessage('openInfo') + expect(messages).toHaveLength(1) + } +} + +class DuckplayerOverlayPixels { + /** + * @param {DuckplayerOverlays} overlays + */ + constructor (overlays) { + this.overlays = overlays + } + + /** + * @param {{pixelName: string, params: Record}[]} pixels + * @return {Promise} + */ + async sendsPixels (pixels) { + const messages = await this.overlays.waitForMessage('sendDuckPlayerPixel') + const params = messages.map(x => x.payload.params) + expect(params).toMatchObject(pixels) + } +} + /** * @param {configFiles[number]} name * @return {Record} diff --git a/integration-test/playwright/type-helpers.mjs b/integration-test/playwright/type-helpers.mjs index 703a1a0a72..e62f1cf6e8 100644 --- a/integration-test/playwright/type-helpers.mjs +++ b/integration-test/playwright/type-helpers.mjs @@ -58,6 +58,7 @@ export class Build { get artifact () { const path = this.switch({ windows: () => 'build/windows/contentScope.js', + android: () => 'build/android/contentScope.js', 'apple': () => './Sources/ContentScopeScripts/dist/contentScope.js', 'apple-isolated': () => './Sources/ContentScopeScripts/dist/contentScopeIsolated.js' }) diff --git a/integration-test/test-pages/duckplayer/pages/player.html b/integration-test/test-pages/duckplayer/pages/player.html index ac4c6012b3..2dfa7c3f58 100644 --- a/integration-test/test-pages/duckplayer/pages/player.html +++ b/integration-test/test-pages/duckplayer/pages/player.html @@ -9,21 +9,34 @@ *, *:before, *:after { box-sizing: border-box; } + body { + max-width: 100%; + margin-left: 0; + margin-right: 0; + } + .controls { + padding: 1em 0; + } + [data-layout=mobile] body { + padding: 0; + margin: 0; + } .container { max-width: 800px; aspect-ratio: 16/9; background: blue; } + [data-layout=mobile] .container { + max-width: none; + } #player { height: 100%; } .html5-video-player { position: relative; height: 100%; - border: 2px dotted red; - } - body { - max-width: 100%; + background: black; + background-size: contain; } .tools { margin-bottom: 1rem; @@ -57,6 +70,47 @@

[Duck Player]

+
+
+ + +
+ +
+ + +
@@ -103,13 +157,16 @@ if (!new URLSearchParams(location.search).has('preview')) { return; } - await import("/build/integration/contentScope.js").catch(console.error) - const overlays = await fetch('/integration-test/test-pages/duckplayer/config/overlays.json').then(x => x.json()) + const platformName = new URLSearchParams(window.location.search).get('platform') || 'macos'; + const locale = new URLSearchParams(window.location.search).get('locale') || 'en'; + await import("/build/contentScope.js").catch(console.error) + const overlays = await fetch('/duckplayer/config/overlays.json').then(x => x.json()) document.dispatchEvent(new CustomEvent('content-scope-init-args', { detail: { debug: true, + locale: locale, platform: { - name: 'macos' + name: platformName, }, site: { domain: location.hostname, @@ -138,6 +195,10 @@ main.innerHTML += html('template[id="related-template"]') main.innerHTML += html('template[id="playlist-template"]') }, + "mobile": () => { + main.innerHTML += html('template[id="inner-template"]'); + document.documentElement.dataset.layout="mobile" + }, "incremental-dom": () => { main.innerHTML += `
` setTimeout(() => { diff --git a/integration-test/test-pages/duckplayer/scripts/test.mjs b/integration-test/test-pages/duckplayer/scripts/test.mjs new file mode 100644 index 0000000000..2626dc0d88 --- /dev/null +++ b/integration-test/test-pages/duckplayer/scripts/test.mjs @@ -0,0 +1,24 @@ +import { DDGVideoOverlayMobile } from "../../../../src/features/duckplayer/components/ddg-video-overlay-mobile.js"; +import { overlayCopyVariants } from "../../../../src/features/duckplayer/text.js"; + +customElements.define(DDGVideoOverlayMobile.CUSTOM_TAG_NAME, DDGVideoOverlayMobile) + +const elem = /** @type {DDGVideoOverlayMobile} */(document.createElement(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)) +elem.testMode = true +elem.text = overlayCopyVariants.a1 + +elem.addEventListener('opt-in', (/** @type {CustomEvent} */e) => { + console.log('did opt in?', e.detail) +}) + +elem.addEventListener('opt-out', (/** @type {CustomEvent} */e) => { + console.log('did opt out?', e.detail) +}) + +elem.addEventListener('open-info', (e) => { + console.log('did open info') +}) + +document.querySelector('.html5-video-player')?.append(elem); + + diff --git a/playwright.config.js b/playwright.config.js index a72927594d..fdc33a17ff 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -30,6 +30,20 @@ export default defineConfig({ ], use: { injectName: 'apple', platform: 'macos' } }, + { + name: 'ios', + testMatch: [ + 'integration-test/playwright/duckplayer-mobile.spec.js' + ], + use: { injectName: 'apple-isolated', platform: 'ios', ...devices['iPhone 13'] } + }, + { + name: 'android', + testMatch: [ + 'integration-test/playwright/duckplayer-mobile.spec.js' + ], + use: { injectName: 'android', platform: 'android', ...devices['Galaxy S5'] } + }, { name: 'chrome', testMatch: 'integration-test/playwright/remote-pages.spec.js', diff --git a/scripts/inject.js b/scripts/inject.js index 7e8c1ba75e..0c2948f553 100644 --- a/scripts/inject.js +++ b/scripts/inject.js @@ -42,7 +42,8 @@ const builds = { output: [ 'build/integration/contentScope.js', 'integration-test/extension/contentScope.js', - 'integration-test/pages/build/contentScope.js' + 'integration-test/pages/build/contentScope.js', + 'integration-test/test-pages/build/contentScope.js' ] }, 'chrome-mv3': { diff --git a/src/features.js b/src/features.js index c3cc3396ae..a33435496d 100644 --- a/src/features.js +++ b/src/features.js @@ -43,7 +43,8 @@ export const platformSupport = { ...baseFeatures, 'webCompat', 'clickToLoad', - 'breakageReporting' + 'breakageReporting', + 'duckPlayer' ], windows: [ 'cookie', diff --git a/src/features/duck-player.js b/src/features/duck-player.js index ab5306ccfc..b8588a5f8b 100644 --- a/src/features/duck-player.js +++ b/src/features/duck-player.js @@ -49,6 +49,7 @@ import { Environment, initOverlays } from './duckplayer/overlays.js' /** * @typedef UISettings - UI-specific settings * @property {'default'|'a1'|'b1'} overlayCopy - Overlay copy experiment variant + * @property {boolean} [allowFirstVideo] - should the first video be allowed to load/play? * @property {boolean} [playInDuckPlayer] - Forces next video to be played in Duck Player regardless of user setting */ @@ -62,6 +63,7 @@ import { Environment, initOverlays } from './duckplayer/overlays.js' * @internal */ export default class DuckPlayerFeature extends ContentFeature { + // eslint-disable-next-line @typescript-eslint/no-unused-vars init (args) { /** * This feature never operates in a frame @@ -95,10 +97,12 @@ export default class DuckPlayerFeature extends ContentFeature { throw new Error('cannot operate duck player without a messaging backend') } + const locale = args?.locale || args?.language || 'en' const env = new Environment({ - debug: args.debug, + debug: true, injectName: import.meta.injectName, - platform: this.platform + platform: this.platform, + locale }) const comms = new DuckPlayerOverlayMessages(this.messaging, env) diff --git a/src/features/duckplayer/assets/info.svg b/src/features/duckplayer/assets/info.svg new file mode 100644 index 0000000000..c81a846464 --- /dev/null +++ b/src/features/duckplayer/assets/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/features/duckplayer/assets/mobile-video-overlay.css b/src/features/duckplayer/assets/mobile-video-overlay.css new file mode 100644 index 0000000000..2e0ef7471b --- /dev/null +++ b/src/features/duckplayer/assets/mobile-video-overlay.css @@ -0,0 +1,360 @@ +/* -- VIDEO PLAYER OVERLAY */ +:host { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + color: white; + 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 { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + color: white; + z-index: 10000; + 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); + text-align: center; +} + +.bg:before { + content: " "; + position: absolute; + display: block; + width: 100%; + height: 100%; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + linear-gradient(180deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.5) 40%, rgba(0, 0, 0, 0) 60%), + radial-gradient(circle at bottom, rgba(131, 58, 180, 0.8), rgba(253, 29, 29, 0.6), rgba(252, 176, 69, 0.4)); +} + +.bg:after { + content: " "; + position: absolute; + display: block; + width: 100%; + height: 100%; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.7); + text-align: center; +} + +.content { + height: 100%; + width: 100%; + margin: 0 auto; + overflow: hidden; + display: grid; + color: rgba(255, 255, 255, 0.96); + position: relative; + grid-column-gap: var(--logo-gap); + grid-template-columns: var(--logo-size) auto calc(12px + 16px); + grid-template-rows: + auto + var(--title-gap) + auto + var(--button-gap) + auto; + align-content: center; + justify-content: center; + + @media screen and (min-width: 568px) { + grid-template-columns: var(--logo-size) auto auto; + } +} + +.logo { + align-self: start; + grid-column: 1/2; + grid-row: 1/2; +} + +.logo svg { + width: 100%; + height: 100%; +} + +.arrow { + position: absolute; + top: 48px; + left: -18px; + color: white; + z-index: 0; +} + +.title { + font-size: var(--title-size); + line-height: var(--title-line-height); + font-weight: 600; + grid-column: 2/3; + grid-row: 1/2; + + @media screen and (min-width: 568px) { + grid-column: 2/4; + max-width: 428px; + } +} + +.text { + display: none; +} + +.info { + grid-column: 3/4; + grid-row: 1/2; + align-self: start; + padding-top: 3px; + justify-self: end; + + @media screen and (min-width: 568px) { + grid-column: unset; + grid-row: unset; + position: absolute; + top: 12px; + right: 12px; + } + @media screen and (min-width: 844px) { + top: 24px; + right: 24px; + } +} + +.buttons { + gap: 8px; + display: flex; + grid-column: 1/4; + grid-row: 3/4; + + @media screen and (min-width: 568px) { + grid-column: 2/3; + } +} + +.remember { + height: 40px; + border-radius: 8px; + display: flex; + gap: 16px; + align-items: center; + justify-content: space-between; + padding-left: 8px; + padding-right: 8px; + grid-column: 1/4; + grid-row: 5/6; + + @media screen and (min-width: 568px) { + grid-column: 2/3; + } +} + +.button { + margin: 0; + -webkit-appearance: none; + background: none; + box-shadow: none; + border: none; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 1); + text-decoration: none; + line-height: 16px; + padding: 0 12px; + font-size: 15px; + font-weight: 600; + border-radius: 8px; +} + +.button--info { + display: block; + padding: 0; + margin: 0; + width: 16px; + height: 16px; + @media screen and (min-width: 568px) { + width: 24px; + height: 24px; + } + @media screen and (min-width: 844px) { + width: 24px; + height: 24px; + } +} +.button--info svg { + display: block; + width: 100%; + height: 100%; +} + +.button--info svg path { + fill: rgba(255, 255, 255, 0.84); +} + +.cancel { + background: rgba(255, 255, 255, 0.3); + min-height: 40px; +} + +.open { + background: #3969EF; + flex: 1; + text-align: center; + min-height: 40px; + + @media screen and (min-width: 568px) { + flex: inherit; + padding-left: 24px; + padding-right: 24px; + } +} + +.open:hover { +} +.cancel:hover { +} + +.remember-label { + display: flex; + align-items: center; + flex: 1; +} + +.remember-text { + display: block; + font-size: 13px; + font-weight: 400; +} +.remember-checkbox { + margin-left: auto; + display: flex; +} + +.switch { + margin: 0; + padding: 0; + width: 52px; + height: 32px; + border: 0; + box-shadow: none; + background: rgba(136, 136, 136, 0.5); + border-radius: 32px; + position: relative; + transition: all .3s; +} + +.switch:active .thumb { + scale: 1.15; +} + +.thumb { + width: 20px; + height: 20px; + border-radius: 100%; + background: white; + position: absolute; + top: 4px; + left: 4px; + pointer-events: none; + transition: .2s left ease-in-out; +} + +.switch[aria-checked="true"] { + background: rgba(57, 105, 239, 1) +} + +.ios-switch { + width: 42px; + height: 24px; +} + +.ios-switch .thumb { + top: 2px; + left: 2px; + width: 20px; + height: 20px; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.25) +} + +.ios-switch:active .thumb { + scale: 1; +} + +.ios-switch[aria-checked="true"] .thumb { + left: calc(100% - 22px) +} + +.android {} diff --git a/src/features/duckplayer/components/ddg-video-overlay-mobile.js b/src/features/duckplayer/components/ddg-video-overlay-mobile.js new file mode 100644 index 0000000000..4f11f88bef --- /dev/null +++ b/src/features/duckplayer/components/ddg-video-overlay-mobile.js @@ -0,0 +1,133 @@ +import mobilecss from '../assets/mobile-video-overlay.css' +import dax from '../assets/dax.svg' +import info from '../assets/info.svg' +import { createPolicy, html, trustedUnsafe } from '../../../dom-utils.js' + +/** + * @typedef {ReturnType} TextVariants + * @typedef {TextVariants[keyof TextVariants]} Text + */ + +/** + * The custom element that we use to present our UI elements + * over the YouTube player + */ +export class DDGVideoOverlayMobile extends HTMLElement { + static CUSTOM_TAG_NAME = 'ddg-video-overlay-mobile' + static OPEN_INFO = 'open-info' + static OPT_IN = 'opt-in' + static OPT_OUT = 'opt-out' + + policy = createPolicy() + /** @type {boolean} */ + testMode = false + /** @type {Text | null} */ + text = null + + connectedCallback () { + this.createMarkupAndStyles() + } + + createMarkupAndStyles () { + const shadow = this.attachShadow({ mode: this.testMode ? 'open' : 'closed' }) + const style = document.createElement('style') + style.innerText = mobilecss + const overlayElement = document.createElement('div') + const content = this.mobileHtml() + overlayElement.innerHTML = this.policy.createHTML(content) + shadow.append(style, overlayElement) + this.setupEventHandlers(overlayElement) + } + + /** + * @returns {string} + */ + mobileHtml () { + if (!this.text) { + console.warn('missing `text`. Please assign before rendering') + return '' + } + const svgIcon = trustedUnsafe(dax) + const infoIcon = trustedUnsafe(info) + return html` +
+
+
+ +
${this.text.title}
+
+ +
+
+ ${this.text.subtitle} +
+
+ + ${this.text.buttonOpen} +
+
+
+ + ${this.text.rememberLabel} + + + + + +
+
+
+
+ `.toString() + } + + /** + * @param {HTMLElement} containerElement + */ + setupEventHandlers (containerElement) { + const switchElem = containerElement.querySelector('[role=switch]') + const infoButton = containerElement.querySelector('.button--info') + const remember = containerElement.querySelector('input[name="ddg-remember"]') + const cancelElement = containerElement.querySelector('.ddg-vpo-cancel') + const watchInPlayer = containerElement.querySelector('.ddg-vpo-open') + + if (!infoButton || + !cancelElement || + !watchInPlayer || + !switchElem || + !(remember instanceof HTMLInputElement)) return console.warn('missing elements') + + infoButton.addEventListener('click', () => { + this.dispatchEvent(new Event(DDGVideoOverlayMobile.OPEN_INFO)) + }) + + switchElem.addEventListener('pointerdown', () => { + const current = switchElem.getAttribute('aria-checked') + if (current === 'false') { + switchElem.setAttribute('aria-checked', 'true') + remember.checked = true + } else { + switchElem.setAttribute('aria-checked', 'false') + remember.checked = false + } + }) + + cancelElement.addEventListener('click', (e) => { + if (!e.isTrusted) return + e.preventDefault() + e.stopImmediatePropagation() + this.dispatchEvent(new CustomEvent(DDGVideoOverlayMobile.OPT_OUT, { detail: { remember: remember.checked } })) + }) + + watchInPlayer.addEventListener('click', (e) => { + if (!e.isTrusted) return + e.preventDefault() + e.stopImmediatePropagation() + this.dispatchEvent(new CustomEvent(DDGVideoOverlayMobile.OPT_IN, { detail: { remember: remember.checked } })) + }) + } +} diff --git a/src/features/duckplayer/components/index.js b/src/features/duckplayer/components/index.js index fef58f7d1d..baa51e4abb 100644 --- a/src/features/duckplayer/components/index.js +++ b/src/features/duckplayer/components/index.js @@ -1,5 +1,6 @@ import { DDGVideoOverlay } from './ddg-video-overlay.js' import { customElementsDefine, customElementsGet } from '../../../captured-globals.js' +import { DDGVideoOverlayMobile } from './ddg-video-overlay-mobile.js' /** * Register custom elements in this wrapper function to be called only when we need to @@ -10,4 +11,7 @@ export function registerCustomElements () { if (!customElementsGet(DDGVideoOverlay.CUSTOM_TAG_NAME)) { customElementsDefine(DDGVideoOverlay.CUSTOM_TAG_NAME, DDGVideoOverlay) } + if (!customElementsGet(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)) { + customElementsDefine(DDGVideoOverlayMobile.CUSTOM_TAG_NAME, DDGVideoOverlayMobile) + } } diff --git a/src/features/duckplayer/constants.js b/src/features/duckplayer/constants.js index 0a5c3387b4..c45942683e 100644 --- a/src/features/duckplayer/constants.js +++ b/src/features/duckplayer/constants.js @@ -3,6 +3,7 @@ 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' diff --git a/src/features/duckplayer/icon-overlay.js b/src/features/duckplayer/icon-overlay.js index b3a6b09a9a..33787f3447 100644 --- a/src/features/duckplayer/icon-overlay.js +++ b/src/features/duckplayer/icon-overlay.js @@ -173,9 +173,7 @@ export class IconOverlay { appendHoverOverlay (onClick) { this.sideEffects.add('Adding the re-usable overlay to the page ', () => { // add the CSS to the head - const style = document.createElement('style') - style.textContent = css - document.head.appendChild(style) + const cleanUpCSS = this.loadCSS() // create and append the element const element = this.create('fixed', '', this.HOVER_CLASS) @@ -185,11 +183,29 @@ export class IconOverlay { return () => { element.remove() - document.head.removeChild(style) + cleanUpCSS() } }) } + loadCSS () { + // add the CSS to the head + const id = '__ddg__icon' + const style = document.head.querySelector(`#${id}`) + if (!style) { + const style = document.createElement('style') + style.id = id + style.textContent = css + document.head.appendChild(style) + } + return () => { + const style = document.head.querySelector(`#${id}`) + if (style) { + document.head.removeChild(style) + } + } + } + /** * @param {HTMLElement} container * @param {string} href @@ -197,6 +213,9 @@ export class IconOverlay { */ appendSmallVideoOverlay (container, href, onClick) { this.sideEffects.add('Adding a small overlay for the video player', () => { + // add the CSS to the head + const cleanUpCSS = this.loadCSS() + const element = this.create('video-player', href, 'hidden') this.addClickHandler(element, onClick) @@ -206,6 +225,7 @@ export class IconOverlay { return () => { element?.remove() + cleanUpCSS() } }) } diff --git a/src/features/duckplayer/overlay-messages.js b/src/features/duckplayer/overlay-messages.js index 84ad10f143..b2eb2772b5 100644 --- a/src/features/duckplayer/overlay-messages.js +++ b/src/features/duckplayer/overlay-messages.js @@ -76,6 +76,13 @@ export class DuckPlayerOverlayMessages { return this.messaging.notify(constants.MSG_NAME_OPEN_PLAYER, params) } + /** + * This is sent when the user wants to open Duck Player. + */ + openInfo () { + return this.messaging.notify(constants.MSG_NAME_OPEN_INFO) + } + /** * Get notification when preferences/state changed * @param {(userValues: import("../duck-player.js").UserValues) => void} cb @@ -114,15 +121,19 @@ export class DuckPlayerOverlayMessages { try { assertCustomEvent(evt) if (evt.detail.kind === constants.MSG_NAME_SET_VALUES) { - this.setUserValues(evt.detail.data) + return this.setUserValues(evt.detail.data) .then(updated => respond(constants.MSG_NAME_PUSH_DATA, updated)) .catch(console.error) } if (evt.detail.kind === constants.MSG_NAME_READ_VALUES_SERP) { - this.getUserValues() + return this.getUserValues() .then(updated => respond(constants.MSG_NAME_PUSH_DATA, updated)) .catch(console.error) } + if (evt.detail.kind === constants.MSG_NAME_OPEN_INFO) { + return this.openInfo() + } + console.warn('unhandled event', evt) } catch (e) { console.warn('cannot handle this message', e) } diff --git a/src/features/duckplayer/overlays.js b/src/features/duckplayer/overlays.js index 24a7f6326c..3b7eb2660c 100644 --- a/src/features/duckplayer/overlays.js +++ b/src/features/duckplayer/overlays.js @@ -2,6 +2,7 @@ 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 @@ -30,6 +31,8 @@ export async function initOverlays (settings, environment, messages) { return } + console.log('did get initial setup ', initialSetup) + if (!initialSetup) { console.error('cannot continue without user settings') return @@ -117,7 +120,9 @@ function thumbnailOverlays ({ userValues, settings, messages, environment, ui }) // must be in 'always ask' mode 'alwaysAsk' in userValues.privatePlayerMode, // must not be set to play in DuckPlayer - ui?.playInDuckPlayer !== true + ui?.playInDuckPlayer !== true, + // must be a desktop layout + environment.layout === 'desktop' ] // Only show thumbnails if ALL conditions above are met @@ -167,17 +172,26 @@ function videoOverlaysFeatureFromSettings ({ userValues, settings, messages, env 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'] } /** @@ -256,4 +270,28 @@ export class Environment { 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/src/features/duckplayer/text.js b/src/features/duckplayer/text.js index ea7e2fb030..35722c81ab 100644 --- a/src/features/duckplayer/text.js +++ b/src/features/duckplayer/text.js @@ -105,3 +105,17 @@ export const overlayCopyVariants = { rememberLabel: i18n.t('rememberLabel') } } + +/** + * @param {Record} lookup + * @returns {OverlayCopyTranslation} + */ +export const mobileStrings = (lookup) => { + return { + title: lookup.videoOverlayTitle2, + subtitle: lookup.videoOverlaySubtitle2, + buttonOptOut: lookup.videoButtonOptOut2, + buttonOpen: lookup.videoButtonOpen2, + rememberLabel: lookup.rememberLabel + } +} diff --git a/src/features/duckplayer/util.js b/src/features/duckplayer/util.js index 9b2009ff33..0bef42dcb4 100644 --- a/src/features/duckplayer/util.js +++ b/src/features/duckplayer/util.js @@ -80,6 +80,14 @@ export function appendImageAsBackground (parent, targetSelector, imageUrl) { } export class SideEffects { + /** + * @param {object} params + * @param {boolean} [params.debug] + */ + constructor ({ debug = false } = { }) { + this.debug = debug + } + /** @type {{fn: () => void, name: string}[]} */ _cleanups = [] /** @@ -90,7 +98,9 @@ export class SideEffects { */ add (name, fn) { try { - // console.log('☢️', name) + if (this.debug) { + console.log('☢️', name) + } const cleanup = fn() if (typeof cleanup === 'function') { this._cleanups.push({ name, fn: cleanup }) @@ -107,7 +117,9 @@ export class SideEffects { for (const cleanup of this._cleanups) { if (typeof cleanup.fn === 'function') { try { - // console.log('🗑️', cleanup.name) + if (this.debug) { + console.log('🗑️', cleanup.name) + } cleanup.fn() } catch (e) { console.error(`cleanup ${cleanup.name} threw`, e) diff --git a/src/features/duckplayer/video-overlay.js b/src/features/duckplayer/video-overlay.js index b6c695a817..55439b5821 100644 --- a/src/features/duckplayer/video-overlay.js +++ b/src/features/duckplayer/video-overlay.js @@ -31,6 +31,8 @@ import { SideEffects, VideoParams } from './util.js' import { DDGVideoOverlay } from './components/ddg-video-overlay.js' import { OpenInDuckPlayerMsg, Pixel } from './overlay-messages.js' import { IconOverlay } from './icon-overlay.js' +import { mobileStrings } from './text.js' +import { DDGVideoOverlayMobile } from './components/ddg-video-overlay-mobile.js' /** * Handle the switch between small & large overlays @@ -42,6 +44,9 @@ export class VideoOverlay { /** @type {string | null} */ lastVideoId = null + /** @type {boolean} */ + didAllowFirstVideo = false + /** * @param {object} options * @param {import("../duck-player.js").UserValues} options.userValues @@ -201,8 +206,14 @@ export class VideoOverlay { // if there's a one-time-override (eg: a link from the serp), then do nothing if (this.environment.hasOneTimeOverride()) return + // should the first video be allowed to play? + if (this.ui.allowFirstVideo === true && !this.didAllowFirstVideo) { + this.didAllowFirstVideo = true + return console.count('Allowing the first video') + } + // if the user previously clicked 'watch here + remember', just add the small dax - if (userValues.overlayInteracted) { + if (this.userValues.overlayInteracted) { return this.addSmallDaxOverlay(params) } @@ -218,24 +229,42 @@ export class VideoOverlay { * @param {import("./util").VideoParams} params */ appendOverlayToPage (targetElement, params) { - this.sideEffects.add(`appending ${DDGVideoOverlay.CUSTOM_TAG_NAME} to the page`, () => { + this.sideEffects.add(`appending ${DDGVideoOverlay.CUSTOM_TAG_NAME} or ${DDGVideoOverlayMobile.CUSTOM_TAG_NAME} to the page`, () => { this.messages.sendPixel(new Pixel({ name: 'overlay' })) + const controller = new AbortController() + const { environment } = this - const { environment, ui } = this - const overlayElement = new DDGVideoOverlay({ - environment, - params, - ui, - manager: this - }) - targetElement.appendChild(overlayElement) + if (this.environment.layout === 'mobile') { + const elem = /** @type {DDGVideoOverlayMobile} */(document.createElement(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)) + elem.testMode = this.environment.isTestMode() + elem.text = mobileStrings(this.environment.strings) + 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) + }) + elem.addEventListener(DDGVideoOverlayMobile.OPT_IN, (/** @type {CustomEvent<{remember: boolean}>} */e) => { + return this.mobileOptIn(e.detail.remember, params) + .catch(console.error) + }) + targetElement.appendChild(elem) + } else { + const elem = new DDGVideoOverlay({ + environment, + params, + ui: this.ui, + manager: this + }) + targetElement.appendChild(elem) + } /** * To cleanup just find and remove the element */ return () => { - const prevOverlayElement = document.querySelector(DDGVideoOverlay.CUSTOM_TAG_NAME) - prevOverlayElement?.remove() + document.querySelector(DDGVideoOverlay.CUSTOM_TAG_NAME)?.remove() + document.querySelector(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)?.remove() + controller.abort() } }) } @@ -337,6 +366,70 @@ export class VideoOverlay { } } + /** + * @param {boolean} remember + * @param {import("./util").VideoParams} params + */ + async mobileOptIn (remember, params) { + const pixel = remember + ? new Pixel({ name: 'play.use', remember: '1' }) + : new Pixel({ name: 'play.use', remember: '0' }) + + this.messages.sendPixel(pixel) + + /** @type {import("../duck-player.js").UserValues} */ + const outgoing = { + overlayInteracted: false, + privatePlayerMode: remember + ? { enabled: {} } + : { alwaysAsk: {} } + } + + const result = await this.messages.setUserValues(outgoing) + + if (this.environment.debug) { + console.log('did receive new values', result) + } + + return this.messages.openDuckPlayer(new OpenInDuckPlayerMsg({ href: params.toPrivatePlayerUrl() })) + } + + /** + * @param {boolean} remember + */ + async mobileOptOut (remember) { + const pixel = remember + ? new Pixel({ name: 'play.do_not_use', remember: '1' }) + : new Pixel({ name: 'play.do_not_use', remember: '0' }) + + this.messages.sendPixel(pixel) + + if (!remember) { + return this.destroy() + } + + /** @type {import("../duck-player.js").UserValues} */ + const next = { + privatePlayerMode: { disabled: {} }, + overlayInteracted: false + } + + if (this.environment.debug) { + console.log('sending user values:', next) + } + + const updatedValues = await this.messages.setUserValues(next) + + // this is needed to ensure any future page navigations respect the new settings + this.userValues = updatedValues + + if (this.environment.debug) { + console.log('user values response:', updatedValues) + } + + this.destroy() + } + /** * Remove elements, event listeners etc */ diff --git a/tsconfig.json b/tsconfig.json index c5830aa0a6..5dbb88356f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,7 @@ "exclude": [ "snapshots/script-overload-snapshots", "integration-test/pages", + "integration-test/test-pages", "integration-test/extension", "packages/special-pages/pages/**/public", "packages/special-pages/playwright-report",