diff --git a/.eslintignore b/.eslintignore index a765d8a507..569bcc38e7 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,6 +4,7 @@ docs/ 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..4c452a8731 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: url(https://images.unsplash.com/photo-1720048169707-a32d6dfca0b3); + background-size: contain; } .tools { margin-bottom: 1rem; @@ -57,6 +70,41 @@

[Duck Player]

+
+ + +
+ + +
@@ -103,13 +151,16 @@ if (!new URLSearchParams(location.search).has('preview')) { return; } + const platformName = new URLSearchParams(window.location.search).get('platform') || 'macos'; + const locale = new URLSearchParams(window.location.search).get('locale') || 'en'; 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()) document.dispatchEvent(new CustomEvent('content-scope-init-args', { detail: { debug: true, + locale: locale, platform: { - name: 'macos' + name: platformName, }, site: { domain: location.hostname, @@ -138,6 +189,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/packages/messaging/lib/test-utils.mjs b/packages/messaging/lib/test-utils.mjs index a296d4eea2..b65760ec2e 100644 --- a/packages/messaging/lib/test-utils.mjs +++ b/packages/messaging/lib/test-utils.mjs @@ -196,7 +196,7 @@ export function mockAndroidMessaging(params) { // if it's a notification, simulate the empty response and don't check for a response if (!('id' in msg)) { - return console.warn("no id"); + return; } if (!(msg.method in window.__playwright_01.mockResponses)) { @@ -238,7 +238,7 @@ export function mockResponses(params) { export function waitForCallCount(params) { const outgoing = window.__playwright_01.mocks.outgoing const filtered = outgoing.filter(({ payload }) => params.method === payload.method) - return filtered.length === params.count + return filtered.length >= params.count } /** diff --git a/packages/special-pages/index.mjs b/packages/special-pages/index.mjs index c3a83943fe..aaeaf484df 100644 --- a/packages/special-pages/index.mjs +++ b/packages/special-pages/index.mjs @@ -29,6 +29,7 @@ export const support = { 'integration': ['copy', 'build-js'], 'windows': ['copy', 'build-js'], 'apple': ['copy', 'build-js', 'inline-html'], + 'android': ['copy', 'build-js'] }, /** @type {Partial>} */ errorpage: { diff --git a/packages/special-pages/messages/duckplayer/initialSetup.response.json b/packages/special-pages/messages/duckplayer/initialSetup.response.json index dcfb69c1c6..15910d5afd 100644 --- a/packages/special-pages/messages/duckplayer/initialSetup.response.json +++ b/packages/special-pages/messages/duckplayer/initialSetup.response.json @@ -57,6 +57,10 @@ "enum": ["macos", "windows", "android", "ios"] } } + }, + "localeStrings": { + "type": "string", + "description": "Optional locale-specific strings" } } } diff --git a/packages/special-pages/pages/duckplayer/app/components/App.jsx b/packages/special-pages/pages/duckplayer/app/components/App.jsx deleted file mode 100644 index cd1a3f427e..0000000000 --- a/packages/special-pages/pages/duckplayer/app/components/App.jsx +++ /dev/null @@ -1,199 +0,0 @@ -import { h, Fragment } from "preact"; -import cn from "classnames"; -import styles from "./App.module.css"; -import { Background } from "./Background.jsx"; -import { InfoBar, InfoBarContainer } from "./InfoBar.jsx"; -import { PlayerContainer, PlayerInternal } from "./PlayerContainer.jsx"; -import { Player, PlayerError } from "./Player.jsx"; -import { - useLayout, - useOpenInfoHandler, - useOpenOnYoutubeHandler, - useOpenSettingsHandler, usePlatformName, useSettings -} from "../providers/SettingsProvider.jsx"; -import { SwitchBarMobile } from "./SwitchBarMobile.jsx"; -import { Button, Icon } from "./Button.jsx"; -import info from "../img/info.data.svg"; -import cog from "../img/cog.data.svg"; -import { BottomNavBar, FloatingBar, TopBar } from "./FloatingBar.jsx"; -import { Wordmark } from "./Wordmark.jsx"; -import { SwitchProvider } from "../providers/SwitchProvider.jsx"; -import { useOrientation } from "../providers/OrientationProvider.jsx"; -import { createAppFeaturesFrom } from "../features/app.js"; -import { useTypedTranslation } from "../types.js"; -import { HideInFocusMode } from "./FocusMode.jsx"; - - -/** - * @param {object} props - * @param {import("../embed-settings.js").EmbedSettings|null} props.embed - */ -export function App({ embed }) { - const layout = useLayout(); - const orientation = useOrientation(); - const settings = useSettings(); - const features = createAppFeaturesFrom(settings) - return ( - <> - - {features.focusMode()} -
- {layout === 'desktop' && } - {layout === 'mobile' && } -
- - ) -} - -/** - * @param {object} props - * @param {import("../embed-settings.js").EmbedSettings|null} props.embed - */ -function DesktopLayout({embed}) { - return ( -
- - {embed === null && } - {embed !== null && } - - - - - - -
- ) -} - -/** - * @param {object} props - * @param {ReturnType} props.orientation - * @param {import("../embed-settings.js").EmbedSettings|null} props.embed - */ -function MobileLayout({orientation, embed}) { - const platformName = usePlatformName(); - const insetPlayer = orientation === "portrait"; - const classes = cn({ - [styles.portrait]: orientation === "portrait", - [styles.landscape]: orientation === "landscape" - }); - return ( -
- {orientation === "portrait" && ( -
- - - - - -
- )} -
-
- - - {embed === null && } - {embed !== null && } - {orientation === "portrait" && ( - - - - )} - - -
- {orientation === "landscape" && } - {orientation === "portrait" && } -
-
- ) -} - -/** - * How the controls are rendered in Portrait mode. - * @param {object} props - * @param {import("../embed-settings.js").EmbedSettings|null} props.embed - */ -function PortraitControls({embed}) { - return ( -
- - - - - - - -
- ) -} - -/** - * How the controls are rendered in Landscape mode - * @param {object} props - * @param {import("../embed-settings.js").EmbedSettings|null} props.embed - * @param {ImportMeta['platform']} props.platformName - The name of the platform. - */ -function LandscapeControls({embed, platformName}) { - return ( -
-
- - - - - -
-
- - - - - -
-
- - - -
-
- ) -} - -/** - * @param {object} props - * @param {import("../embed-settings.js").EmbedSettings|null} props.embed - */ -function MobileFooter({embed}) { - const openSettings = useOpenSettingsHandler(); - const openInfo = useOpenInfoHandler(); - const openOnYoutube = useOpenOnYoutubeHandler(); - const {t} = useTypedTranslation(); - return ( - <> - - - - - ) -} - - diff --git a/packages/special-pages/pages/duckplayer/app/components/Button.module.css b/packages/special-pages/pages/duckplayer/app/components/Button.module.css index 0d53c84829..ee6357cea2 100644 --- a/packages/special-pages/pages/duckplayer/app/components/Button.module.css +++ b/packages/special-pages/pages/duckplayer/app/components/Button.module.css @@ -9,7 +9,7 @@ flex-shrink: 0; box-shadow: none; background: rgba(255, 255, 255, 0.12); - border-radius: 8px; + border-radius: var(--inner-radius); font-weight: bold; color: rgba(255, 255, 255, 1); text-decoration: none; @@ -24,8 +24,6 @@ flex: 1; text-align: center; justify-content: center; - padding-left: 4px; - padding-right: 4px; text-wrap: nowrap; text-overflow: ellipsis; overflow: hidden; diff --git a/packages/special-pages/pages/duckplayer/app/components/DesktopApp.jsx b/packages/special-pages/pages/duckplayer/app/components/DesktopApp.jsx new file mode 100644 index 0000000000..004decbd83 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/DesktopApp.jsx @@ -0,0 +1,48 @@ +import { h, Fragment } from "preact"; +import styles from "./DesktopApp.module.css"; +import { Background } from "./Background.jsx"; +import { InfoBar, InfoBarContainer } from "./InfoBar.jsx"; +import { PlayerContainer } from "./PlayerContainer.jsx"; +import { Player, PlayerError } from "./Player.jsx"; +import { useSettings } from "../providers/SettingsProvider.jsx"; +import { createAppFeaturesFrom } from "../features/app.js"; +import { HideInFocusMode } from "./FocusMode.jsx"; + + +/** + * @param {object} props + * @param {import("../embed-settings.js").EmbedSettings|null} props.embed + */ +export function DesktopApp({ embed }) { + const settings = useSettings(); + const features = createAppFeaturesFrom(settings) + return ( + <> + + {features.focusMode()} +
+ +
+ + ) +} + +/** + * @param {object} props + * @param {import("../embed-settings.js").EmbedSettings|null} props.embed + */ +function DesktopLayout({embed}) { + return ( +
+ + {embed === null && } + {embed !== null && } + + + + + + +
+ ) +} diff --git a/packages/special-pages/pages/duckplayer/app/components/App.module.css b/packages/special-pages/pages/duckplayer/app/components/DesktopApp.module.css similarity index 96% rename from packages/special-pages/pages/duckplayer/app/components/App.module.css rename to packages/special-pages/pages/duckplayer/app/components/DesktopApp.module.css index 47fa6de294..2a7855f882 100644 --- a/packages/special-pages/pages/duckplayer/app/components/App.module.css +++ b/packages/special-pages/pages/duckplayer/app/components/DesktopApp.module.css @@ -1,6 +1,8 @@ :root { /* Set video to take up 80vw width */ --video-width: 80vw; + --outer-radius: 16px; + --inner-radius: 8px; } @media screen and (max-width: 1080px) { @@ -84,7 +86,7 @@ grid-template-columns: 60% 1fr; grid-column-gap: 8px; background: rgba(0, 0, 0, 0.3); - border-radius: 16px; + border-radius: var(--outer-radius); padding: 8px; @media screen and (max-width: 700px) { grid-template-columns: 50% 1fr; diff --git a/packages/special-pages/pages/duckplayer/app/components/FloatingBar.jsx b/packages/special-pages/pages/duckplayer/app/components/FloatingBar.jsx index 0e5ea632e9..b4b8c566d3 100644 --- a/packages/special-pages/pages/duckplayer/app/components/FloatingBar.jsx +++ b/packages/special-pages/pages/duckplayer/app/components/FloatingBar.jsx @@ -2,19 +2,6 @@ import styles from "./FloatingBar.module.css" import { h } from "preact"; import cn from "classnames"; - -/** - * @param {object} props - * @param {import("preact").ComponentChild} props.children - */ -export function BottomNavBar({children}) { - return ( -
- {children} -
- ) -} - /** * @param {object} props * @param {import("preact").ComponentChild} props.children diff --git a/packages/special-pages/pages/duckplayer/app/components/FloatingBar.module.css b/packages/special-pages/pages/duckplayer/app/components/FloatingBar.module.css index 5a244ad1ba..7dc507ebe6 100644 --- a/packages/special-pages/pages/duckplayer/app/components/FloatingBar.module.css +++ b/packages/special-pages/pages/duckplayer/app/components/FloatingBar.module.css @@ -4,15 +4,11 @@ } .inset { - border-radius: 8px; + border-radius: var(--outer-radius); padding: 8px; background: rgba(0, 0, 0, 0.3); } -.bottomNavBar { -} - - .topBar { display: grid; justify-content: center; diff --git a/packages/special-pages/pages/duckplayer/app/components/FocusMode.jsx b/packages/special-pages/pages/duckplayer/app/components/FocusMode.jsx index e760f898d7..9f25a23cfd 100644 --- a/packages/special-pages/pages/duckplayer/app/components/FocusMode.jsx +++ b/packages/special-pages/pages/duckplayer/app/components/FocusMode.jsx @@ -3,6 +3,9 @@ import cn from "classnames"; import { useCallback, useEffect } from "preact/hooks"; import styles from "./FocusMode.module.css"; +const EVENT_ON = 'ddg-duckplayer-focusmode-on' +const EVENT_OFF = 'ddg-duckplayer-focusmode-off' + export function FocusMode() { useEffect(() => { let enabled = true; @@ -12,7 +15,9 @@ export function FocusMode() { // try again after delay wait() } else { - if (!enabled) return; + if (!enabled) { + return console.warn("ignoring focusMode because it was disabled") + } document.documentElement.dataset.focusMode = 'on' } } @@ -36,11 +41,15 @@ export function FocusMode() { // other events that might occur window.addEventListener('frame-mousemove', cancel) - window.addEventListener('ddg-duckplayer-focusmode-off', () => { + window.addEventListener(EVENT_OFF, () => { enabled = false; off() }) - + window.addEventListener(EVENT_ON, () => { + if (enabled === true) return; + enabled = true; + on() + }) return () => { clearTimeout(timerId); } @@ -48,6 +57,9 @@ export function FocusMode() { return null } +FocusMode.disable = () => setTimeout(() => window.dispatchEvent(new Event(EVENT_OFF)), 0) +FocusMode.enable = () => setTimeout(() => window.dispatchEvent(new Event(EVENT_ON)), 0) + /** * Hides the content in focus mode. * diff --git a/packages/special-pages/pages/duckplayer/app/components/InfoBar.jsx b/packages/special-pages/pages/duckplayer/app/components/InfoBar.jsx index 90aba9f346..d868a59f95 100644 --- a/packages/special-pages/pages/duckplayer/app/components/InfoBar.jsx +++ b/packages/special-pages/pages/duckplayer/app/components/InfoBar.jsx @@ -126,7 +126,7 @@ function ControlBarDesktop({embed}) { if (embed) openOnYoutube(embed) } }} - >Watch on YouTube + >{t('watchOnYoutube')} ) } diff --git a/packages/special-pages/pages/duckplayer/app/components/MobileApp.jsx b/packages/special-pages/pages/duckplayer/app/components/MobileApp.jsx new file mode 100644 index 0000000000..3282d3091f --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/MobileApp.jsx @@ -0,0 +1,74 @@ +import { h, Fragment } from "preact"; +import cn from "classnames"; +import styles from "./MobileApp.module.css"; +import { Background } from "./Background.jsx"; +import { Player, PlayerError } from "./Player.jsx"; +import { + usePlatformName, + useSettings +} from "../providers/SettingsProvider.jsx"; +import { SwitchBarMobile } from "./SwitchBarMobile.jsx"; +import { MobileWordmark } from "./Wordmark.jsx"; +import { SwitchProvider } from "../providers/SwitchProvider.jsx"; +import { createAppFeaturesFrom } from "../features/app.js"; +import { MobileButtons } from "./MobileButtons.jsx"; +import { OrientationProvider } from "../providers/OrientationProvider.jsx"; +import { FocusMode } from "./FocusMode.jsx"; + +const DISABLED_HEIGHT = 450; + +/** + * @param {object} props + * @param {import("../embed-settings.js").EmbedSettings|null} props.embed + */ +export function MobileApp({ embed }) { + const settings = useSettings(); + const features = createAppFeaturesFrom(settings) + return ( + <> + + {features.focusMode()} + { + if (orientation === "portrait") { + return FocusMode.enable() + } + // landscape + // if the height is too low, just disable it + if (window.innerHeight < DISABLED_HEIGHT) { + return FocusMode.disable() + } + return FocusMode.enable() + }} /> + + + ) +} + +/** + * @param {object} props + * @param {import("../embed-settings.js").EmbedSettings|null} props.embed + */ +function MobileLayout({embed}) { + const platformName = usePlatformName(); + return ( +
+
+
+ {embed === null && } + {embed !== null && } +
+
+ +
+
+ + + +
+
+ +
+
+ ) +} + diff --git a/packages/special-pages/pages/duckplayer/app/components/MobileApp.module.css b/packages/special-pages/pages/duckplayer/app/components/MobileApp.module.css new file mode 100644 index 0000000000..8549c20cae --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/MobileApp.module.css @@ -0,0 +1,254 @@ +html[data-focus-mode="on"]:root .main { + --bg-color: transparent; +} +.hideInFocus { + opacity: 1; + visibility: visible; + transition: opacity .3s, visibility .3s; +} +html[data-focus-mode="on"] .hideInFocus { + opacity: 0; + visibility: hidden; +} +@keyframes fadeout { + from { + opacity: 1; + visibility: visible; + } + to { + opacity: 0; + visibility: visible; + } +} +.filler { + display: none; +} +.main { + --bg-color: rgba(0, 0, 0, 0.3); + --logo-spacing: 185px; + --ui-control-height: calc(44px + 12px + 12px); + --additional-ui: calc(44px * 3); + --gutter-width: 8px; + --gutter-combined: calc(var(--gutter-width) * 2); + --outer-radius: 16px; + --inner-radius: 8px; + --logo-width: 157px; + --inner-padding: 12px; + position: relative; + max-width: 100vh; + margin: 0 auto; + height: 100%; + display: grid; + grid-template-columns: auto; + --row-1: 0; + --row-2: auto; + --row-3: max-content; + --row-4: max-content; + --row-5: 12px; + --row-6: max-content; + --row-7: auto; + grid-template-rows: + var(--row-1) + var(--row-2) + var(--row-3) + var(--row-4) + var(--row-5) + var(--row-6) + var(--row-7); + grid-template-areas: + 'logo' + 'gap1' + 'embed' + 'buttons' + 'button-gap' + 'switch' + 'gap2'; +} + +/* remove the switch height, to keep everything centered */ +body:has([data-state="completed"] [aria-checked="true"]) .main { + --row-1: 0; + --row-2: auto; + --row-3: max-content; + --row-4: max-content; + --row-5: 0; + --row-6: 0; + --row-7: auto; +} + +body:has([data-state="completed"] [aria-checked="true"]) .switch { + background: transparent; +} + +.embed { + background: var(--bg-color); + grid-area: embed; + padding: var(--inner-padding); + padding-bottom: 0; + border-top-left-radius: var(--outer-radius); + border-top-right-radius: var(--outer-radius); +} + +.logo { + justify-self: center; + grid-area: logo; +} + +.buttons { + grid-area: buttons; + padding: var(--inner-padding); + background: var(--bg-color); + border-bottom-left-radius: var(--outer-radius); + border-bottom-right-radius: var(--outer-radius); +} + +.switch { + grid-area: switch; + height: 50px; + background: rgba(255, 255, 255, 0.03); + border-radius: 16px; +} + +@media screen and (min-width: 425px) and (max-height: 600px) { + .main { + /* reset logo positioning */ + grid-template-rows: + max-content + auto + max-content + max-content + 12px + max-content + auto; + } +} +@media screen and (min-width: 768px) and (min-height: 600px) { + .logo { + justify-self: unset; + background: var(--bg-color); + border-bottom-left-radius: var(--outer-radius); + display: grid; + align-items: center; + padding-left: var(--inner-padding); + } + .buttons { + border-bottom-left-radius: unset; + } + .main { + grid-template-columns: auto minmax(384px, max-content); + grid-template-rows: max-content max-content 12px max-content; + grid-template-areas: + 'embed embed' + 'logo buttons' + 'button-gap button-gap' + 'switch switch'; + align-content: center; + max-width: calc(100vh * 1.3); + } + /* remove the switch height, to keep everything centered */ + body:has([data-state="completed"] [aria-checked="true"]) .main { + grid-template-rows: max-content max-content 0 0; + } +} +@media screen and (min-width: 900px) and (min-height: 660px) { + .logo { + justify-self: unset; + padding-right: 34px; + } + .switch { + background: var(--bg-color); + border-radius: unset; + display: grid; + padding-top: 12px; + padding-bottom: 12px; + height: 100%; + } + .buttons { + padding-left: 8px; + } + .main { + grid-template-columns: max-content auto minmax(384px, max-content); + grid-template-rows: max-content max-content; + grid-template-areas: + 'embed embed embed' + 'logo switch buttons'; + align-content: center; + max-width: calc(100vh * 1.3); + } + body:has([data-state="completed"] [aria-checked="true"]) .switch { + background: var(--bg-color); + } +} +@media screen and (min-width: 600px) and (max-height: 450px) { + .main { + grid-template-columns: 1fr 1fr; + grid-template-rows: calc(44px + 24px) 44px auto calc(44px + 24px); + grid-template-areas: + 'embed logo' + 'embed buttons' + 'embed filler' + 'embed switch'; + align-content: center; + max-width: 100%; + max-height: 90vh; + } + body:has([data-state="completed"] [aria-checked="true"]) .main { + grid-template-rows: max-content max-content 0 0; + } + body:has([data-state="completed"] [aria-checked="true"]) .logo { + padding-top: 0; + align-items: end; + } + body:has([data-state="completed"] [aria-checked="true"]) .buttons { + border-bottom-right-radius: var(--outer-radius); + padding-bottom: 12px; + } + body:has([data-state="completed"] [aria-checked="true"]) .switch { + display: none; + } + .filler { + display: block; + height: 100%; + grid-area: filler; + background: var(--bg-color) + } + .embed { + padding: var(--inner-padding); + border-bottom-left-radius: var(--outer-radius); + border-top-right-radius: 0; + } + .logo { + display: grid; + width: 100%; + background: var(--bg-color); + justify-content: center; + border-top-right-radius: var(--outer-radius); + padding: var(--inner-padding); + padding-left: 0; + } + .buttons { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + padding: 0; + padding-right: var(--inner-padding); + } + .switch { + background: var(--bg-color); + border-top-right-radius: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: var(--outer-radius); + align-self: end; + padding: var(--inner-padding); + padding-left: 0; + height: 100%; + } +} +@media screen and (min-width: 1100px) { + .switch { + justify-content: end; + } + .switch > * { + min-width: 400px + } +} diff --git a/packages/special-pages/pages/duckplayer/app/components/MobileButtons.jsx b/packages/special-pages/pages/duckplayer/app/components/MobileButtons.jsx new file mode 100644 index 0000000000..d2f40560be --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/MobileButtons.jsx @@ -0,0 +1,44 @@ +import { h } from "preact"; +import { useOpenInfoHandler, useOpenOnYoutubeHandler, useOpenSettingsHandler } from "../providers/SettingsProvider.jsx"; +import { useTypedTranslation } from "../types.js"; +import { Button, Icon } from "./Button.jsx"; + +import styles from "./MobileButtons.module.css"; +import info from "../img/info.data.svg"; +import cog from "../img/cog.data.svg"; + +/** + * @param {object} props + * @param {import("../embed-settings.js").EmbedSettings|null} props.embed + */ +export function MobileButtons({embed}) { + const openSettings = useOpenSettingsHandler(); + const openInfo = useOpenInfoHandler(); + const openOnYoutube = useOpenOnYoutubeHandler(); + const {t} = useTypedTranslation(); + return ( +
+ + + +
+ ) +} diff --git a/packages/special-pages/pages/duckplayer/app/components/MobileButtons.module.css b/packages/special-pages/pages/duckplayer/app/components/MobileButtons.module.css new file mode 100644 index 0000000000..bdca03f405 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/MobileButtons.module.css @@ -0,0 +1,5 @@ +.buttons { + display: grid; + grid-template-columns: max-content max-content auto; + grid-column-gap: 8px; +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Player.module.css b/packages/special-pages/pages/duckplayer/app/components/Player.module.css index 116488cf52..77aa9be65a 100644 --- a/packages/special-pages/pages/duckplayer/app/components/Player.module.css +++ b/packages/special-pages/pages/duckplayer/app/components/Player.module.css @@ -1,28 +1,38 @@ .root { z-index: 1; position: relative; + aspect-ratio: 16/9; + border-radius: var(--inner-radius); overflow: hidden; +} + +.root.desktop { height: var(--frame-height); } .player { font-size: 0; } -.desktop { - border-top-left-radius: 16px; - border-top-right-radius: 16px; -} -.mobile { - border-radius: 16px; -} .iframe { + height: 100%; + width: 100%; +} + +.iframe.desktop { height: var(--frame-height); width: 100%; z-index: 1; position: relative; } +.desktop { + border-top-left-radius: var(--outer-radius); + border-top-right-radius: var(--outer-radius); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + .error { height: 100%; display: grid; diff --git a/packages/special-pages/pages/duckplayer/app/components/PlayerContainer.module.css b/packages/special-pages/pages/duckplayer/app/components/PlayerContainer.module.css index 235bfe69e1..aecee5bb69 100644 --- a/packages/special-pages/pages/duckplayer/app/components/PlayerContainer.module.css +++ b/packages/special-pages/pages/duckplayer/app/components/PlayerContainer.module.css @@ -4,7 +4,7 @@ .inset { padding: 8px; - border-radius: 16px; + border-radius: var(--outer-radius); background: rgba(0, 0, 0, 0.3); transition: background 1s; } diff --git a/packages/special-pages/pages/duckplayer/app/components/Switch.module.css b/packages/special-pages/pages/duckplayer/app/components/Switch.module.css index 3a6e726aa0..8ed32ec7f3 100644 --- a/packages/special-pages/pages/duckplayer/app/components/Switch.module.css +++ b/packages/special-pages/pages/duckplayer/app/components/Switch.module.css @@ -54,5 +54,3 @@ .ios[aria-checked="true"] .thumb { left: calc(100% - 32px + 3px) } - -.android {} diff --git a/packages/special-pages/pages/duckplayer/app/components/SwitchBarMobile.jsx b/packages/special-pages/pages/duckplayer/app/components/SwitchBarMobile.jsx index 4738902d31..c76e0c79f3 100644 --- a/packages/special-pages/pages/duckplayer/app/components/SwitchBarMobile.jsx +++ b/packages/special-pages/pages/duckplayer/app/components/SwitchBarMobile.jsx @@ -1,6 +1,6 @@ import { h } from "preact"; import cn from "classnames"; -import styles from "./SwitchBar.module.css" +import styles from "./SwitchBarMobile.module.css" import { useContext } from "preact/hooks"; import { SwitchContext } from "../providers/SwitchProvider.jsx"; import { Switch } from "./Switch.jsx"; diff --git a/packages/special-pages/pages/duckplayer/app/components/SwitchBar.module.css b/packages/special-pages/pages/duckplayer/app/components/SwitchBarMobile.module.css similarity index 66% rename from packages/special-pages/pages/duckplayer/app/components/SwitchBar.module.css rename to packages/special-pages/pages/duckplayer/app/components/SwitchBarMobile.module.css index f8047cd6a8..4b95de7552 100644 --- a/packages/special-pages/pages/duckplayer/app/components/SwitchBar.module.css +++ b/packages/special-pages/pages/duckplayer/app/components/SwitchBarMobile.module.css @@ -1,25 +1,24 @@ .switchBar { - display: flex; - gap: 8px; - border-radius: 8px; - padding: 8px 12px; + display: grid; + border-radius: 16px; background: rgba(255, 255, 255, 0.03); - height: 44px; - line-height: 1; - align-items: center; - width: 100%; - transition: all .3s ease-in-out; - opacity: 1; - visibility: visible; + padding-inline: 16px; + height: 100%; + line-height: 1.1; } -[data-focus-mode="on"] .switchBar { - opacity: 0; - visibility: hidden; +@media screen and (min-width: 900px) { + .switchBar { + border-radius: 8px; + } +} +@media screen and (min-width: 667px) and (max-height: 450px) { + .switchBar { + border-radius: 8px; + } } .stateExiting { - transition: all .3s ease-in-out; transition-delay: 2s; opacity: 0; diff --git a/packages/special-pages/pages/duckplayer/app/components/Tooltip.jsx b/packages/special-pages/pages/duckplayer/app/components/Tooltip.jsx index 227885ddc3..618f0966d2 100644 --- a/packages/special-pages/pages/duckplayer/app/components/Tooltip.jsx +++ b/packages/special-pages/pages/duckplayer/app/components/Tooltip.jsx @@ -1,6 +1,7 @@ import { h } from "preact"; import cn from "classnames"; import styles from "./Tooltip.module.css"; +import { useTypedTranslation } from "../types.js"; /** * @param {object} props @@ -9,6 +10,7 @@ import styles from "./Tooltip.module.css"; * @param {'top' | 'bottom'} props.position */ export function Tooltip({ id, isVisible, position }) { + const { t } = useTypedTranslation(); return ( ) } diff --git a/packages/special-pages/pages/duckplayer/app/components/Wordmark-mobile.module.css b/packages/special-pages/pages/duckplayer/app/components/Wordmark-mobile.module.css new file mode 100644 index 0000000000..a4848af924 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Wordmark-mobile.module.css @@ -0,0 +1,24 @@ +.logo { + height: 44px; + display: grid; + width: 100%; + align-items: center; + grid-column-gap: 8px; + grid-template-columns: max-content max-content; +} +@media screen and (max-width: 500px) { + .logo { + height: 100px; + } +} +.logoSvg { + img { + display: block; + width: 32px; + height: 32px; + } +} +.text { + font-size: 17px; + font-weight: 600; +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Wordmark.jsx b/packages/special-pages/pages/duckplayer/app/components/Wordmark.jsx index d98977b1d9..39497ac3fd 100644 --- a/packages/special-pages/pages/duckplayer/app/components/Wordmark.jsx +++ b/packages/special-pages/pages/duckplayer/app/components/Wordmark.jsx @@ -1,4 +1,5 @@ import styles from "./Wordmark.module.css"; +import mobileStyles from "./Wordmark-mobile.module.css"; import dax from "../img/dax.data.svg"; import { h } from "preact"; @@ -14,3 +15,14 @@ export function Wordmark() { ) } + +export function MobileWordmark() { + return ( +
+ + DuckDuckGo logo + + Duck Player +
+ ) +} diff --git a/packages/special-pages/pages/duckplayer/app/index.css b/packages/special-pages/pages/duckplayer/app/index.css index 8ad770c2ed..bce7da55ac 100644 --- a/packages/special-pages/pages/duckplayer/app/index.css +++ b/packages/special-pages/pages/duckplayer/app/index.css @@ -1,9 +1,14 @@ @import url("base.css"); +html, body { + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} + body[data-display="app"] { color: rgba(242, 242, 242, 1); background: #101010; - height: 100vh; - overflow: hidden; - padding: 8px; + padding: 16px; } diff --git a/packages/special-pages/pages/duckplayer/app/index.js b/packages/special-pages/pages/duckplayer/app/index.js index cbdb260ef2..ee978acc9d 100644 --- a/packages/special-pages/pages/duckplayer/app/index.js +++ b/packages/special-pages/pages/duckplayer/app/index.js @@ -11,9 +11,9 @@ import { SettingsProvider } from './providers/SettingsProvider.jsx' import { MessagingContext } from './types.js' import { UserValuesProvider } from './providers/UserValuesProvider.jsx' import { Fallback } from './components/Fallback.jsx' -import { App } from './components/App.jsx' import { Components } from './components/Components.jsx' -import { OrientationProvider } from './providers/OrientationProvider.jsx' +import { MobileApp } from './components/MobileApp.jsx' +import { DesktopApp } from './components/DesktopApp.jsx' /** * @param {import("../src/js/index.js").DuckplayerPage} messaging @@ -38,23 +38,13 @@ export async function init (messaging, baseEnvironment) { .withDisplay(baseEnvironment.urlParams.get('display')) console.log('environment:', environment) + console.log('locale:', environment.locale) document.body.dataset.display = environment.display - // This will be re-enabled in the mobile PR - // const strings = environment.locale === 'en' - // ? enStrings - // : await fetch(`./locales/${environment.locale}/duckplayer.json`) - // .then(resp => { - // if (!resp.ok) { - // throw new Error('did not give a result') - // } - // return resp.json() - // }) - // .catch(e => { - // console.error('Could not load locale', environment.locale, e) - // return enStrings - // }) + const strings = environment.locale === 'en' + ? enStrings + : await getTranslationsFromStringOrLoadDynamically(init.localeStrings, environment.locale) || enStrings const settings = new Settings({}) .withPlatformName(baseEnvironment.injectName) @@ -87,18 +77,23 @@ export async function init (messaging, baseEnvironment) { willThrow={environment.willThrow}> }> - - - - - - - - - - - - + + + + {settings.layout === 'desktop' && ( + + + + )} + {settings.layout === 'mobile' && ( + + + + )} + + + + , root) @@ -128,3 +123,38 @@ function createEmbedSettings (href, settings) { .withAutoplay(settings.autoplay.state === 'enabled') .withMuted(settings.platform.name === 'ios') } + +/** + * @param {string|null|undefined} stringInput - optional string input. Might be a string of JSON + * @param {string} locale + * @return {Promise | null>} + */ +async function getTranslationsFromStringOrLoadDynamically (stringInput, locale) { + /** + * This is a special situation - the native side (iOS/macOS at the time) wanted to + * use a single HTML file for the error pages. This created an issues since special pages + * would like to load the translation files dynamically. The solution we came up with, + * is to add the translation data as a string on the native side. This keeps all + * the translations in the FE codebase. + */ + if (stringInput) { + try { + return JSON.parse(stringInput) + } catch (e) { + console.warn('String could not be parsed. Falling back to fetch...') + } + } + + // If parsing failed or stringInput was null/undefined, proceed with fetch + try { + const response = await fetch(`./locales/${locale}/duckplayer.json`) + if (!response.ok) { + console.error('Network response was not ok') + return null + } + return await response.json() + } catch (e) { + console.error('Failed to fetch or parse JSON from the network:', e) + return null + } +} diff --git a/packages/special-pages/pages/duckplayer/app/providers/OrientationProvider.jsx b/packages/special-pages/pages/duckplayer/app/providers/OrientationProvider.jsx index 038c47a796..bddf751046 100644 --- a/packages/special-pages/pages/duckplayer/app/providers/OrientationProvider.jsx +++ b/packages/special-pages/pages/duckplayer/app/providers/OrientationProvider.jsx @@ -1,36 +1,54 @@ -import { h } from "preact"; -import { useContext, useEffect, useState } from "preact/hooks"; -import { createContext } from "preact"; - -const OrientationContext = createContext(/** @type {"landscape" | "portrait"} */("portrait")) +import { useEffect } from "preact/hooks"; /** * Device orientation - * - * @param {Object} props - The props for the settings provider. - * @param {import("preact").ComponentChild} props.children - The children components to be wrapped by the settings provider. + * @param {object} props + * @param {(orientation: 'portrait' | 'landscape') => void} props.onChange */ -export function OrientationProvider ({ children }) { - const [orientation, setTheme] = useState(() => { - const initial = window.innerWidth > window.innerHeight ? 'landscape' : 'portrait' - return /** @type {"landscape"|"portrait"} */(initial) - }) +export function OrientationProvider ({ onChange }) { + useEffect(() => { + onChange(getOrientationFromScreen()) + const handleOrientationChange = () => { + onChange(getOrientationFromScreen()); + }; + screen.orientation.addEventListener('change', handleOrientationChange); + return () => screen.orientation.removeEventListener('change', handleOrientationChange); + }, []); useEffect(() => { - const listener = (e) => setTheme(window.innerWidth > window.innerHeight ? 'landscape' : 'portrait') + let timer; + const listener = () => { + clearTimeout(timer); + timer = setTimeout(() => onChange(getOrientationFromWidth()), 300) + } window.addEventListener('resize', listener) return () => window.removeEventListener('resize', listener) }, []) - useEffect(() => { - document.body.dataset.orientation = orientation - }, [orientation]) + return null +} + - return - {children} - +/** + * Retrieves the current orientation of the screen. + * + * The orientation can either be 'portrait' or 'landscape' based on the height and width of the window. + * + * If the height of the window is greater than 500, then the orientation is considered 'portrait'. Otherwise, + * if the screen.orientation.type includes the word 'landscape', then the orientation is considered 'landscape'. + * Otherwise, the orientation is 'portrait'. + * + * @return {"portrait" | "landscape"} The current orientation of the screen. It can be either 'portrait' or 'landscape'. + */ +function getOrientationFromWidth() { + return window.innerWidth > window.innerHeight ? 'landscape' : 'portrait' } -export function useOrientation() { - return useContext(OrientationContext) +/** + * @return {"portrait" | "landscape"} The current orientation of the screen. It can be either 'portrait' or 'landscape'. + */ +function getOrientationFromScreen() { + return screen.orientation.type.includes('landscape') + ? 'landscape' + : 'portrait' } diff --git a/packages/special-pages/pages/duckplayer/app/providers/SwitchProvider.jsx b/packages/special-pages/pages/duckplayer/app/providers/SwitchProvider.jsx index 6f1e9d4c9a..27aad3c10d 100644 --- a/packages/special-pages/pages/duckplayer/app/providers/SwitchProvider.jsx +++ b/packages/special-pages/pages/duckplayer/app/providers/SwitchProvider.jsx @@ -23,7 +23,6 @@ export const SwitchContext = createContext({ }) export function SwitchProvider({ children }) { - const { isReducedMotion } = useEnv(); const userValues = useUserValues(); const setEnabled = useSetEnabled(); const initialState = 'enabled' in userValues.privatePlayerMode ? 'completed' : 'showing' diff --git a/packages/special-pages/pages/duckplayer/app/settings.js b/packages/special-pages/pages/duckplayer/app/settings.js index 4a22ca3ca9..3cd376d504 100644 --- a/packages/special-pages/pages/duckplayer/app/settings.js +++ b/packages/special-pages/pages/duckplayer/app/settings.js @@ -101,21 +101,4 @@ export class Settings { default: return 'desktop' } } - - /** - * @return {'desktop' | 'portrait' | 'landscape'} - */ - get orientation () { - switch (this.platform.name) { - case 'windows': - case 'macos': { - return 'desktop' - } - case 'ios': - case 'android': { - return 'portrait' - } - default: return 'desktop' - } - } } diff --git a/packages/special-pages/pages/duckplayer/src/js/index.js b/packages/special-pages/pages/duckplayer/src/js/index.js index 368881e813..a4d7f40265 100644 --- a/packages/special-pages/pages/duckplayer/src/js/index.js +++ b/packages/special-pages/pages/duckplayer/src/js/index.js @@ -1,3 +1,4 @@ +import 'preact/devtools' import { createTypedMessages } from '@duckduckgo/messaging' import { Environment } from '../../../../shared/environment.js' import { createSpecialPageMessaging } from '../../../../shared/create-special-page-messaging.js' diff --git a/packages/special-pages/playwright.config.js b/packages/special-pages/playwright.config.js index e301d1c9d4..8543a27e7a 100644 --- a/packages/special-pages/playwright.config.js +++ b/packages/special-pages/playwright.config.js @@ -30,6 +30,41 @@ export default defineConfig({ injectName: 'apple', platform: 'macos' } + }, + { + name: 'android', + testMatch: [ + 'duckplayer.spec.js', + 'duckplayer-screenshots.spec.js' + ], + use: { + ...devices['Galaxy S III'], + injectName: 'android', + platform: 'android' + } + }, + { + name: 'android-landscape', + testMatch: [ + 'duckplayer-screenshots.spec.js' + ], + use: { + ...devices['Galaxy S III landscape'], + injectName: 'android', + platform: 'android' + } + }, + { + name: 'ios', + testMatch: [ + 'duckplayer.spec.js', + 'duckplayer-screenshots.spec.js' + ], + use: { + ...devices['iPhone 14'], + injectName: 'apple', + platform: 'ios' + } } // TODO: Add iOS ], diff --git a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-android-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-android-darwin.png index edbb9894bc..8cf8c702bc 100644 Binary files a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-android-darwin.png and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-android-darwin.png differ diff --git a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-android-landscape-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-android-landscape-darwin.png index 1fc263881e..22055ffc24 100644 Binary files a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-android-landscape-darwin.png and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-android-landscape-darwin.png differ diff --git a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-ios-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-ios-darwin.png index a32adb8604..4f6b5aeb48 100644 Binary files a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-ios-darwin.png and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-ios-darwin.png differ diff --git a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-android-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-android-darwin.png index 839517edfb..b677d48812 100644 Binary files a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-android-darwin.png and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-android-darwin.png differ diff --git a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-android-landscape-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-android-landscape-darwin.png index 5ff99ff879..2fe9d44f1a 100644 Binary files a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-android-landscape-darwin.png and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-android-landscape-darwin.png differ diff --git a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-ios-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-ios-darwin.png index d747a6bf0c..03bed73dde 100644 Binary files a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-ios-darwin.png and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-ios-darwin.png differ diff --git a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-android-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-android-darwin.png index db30f84327..1fb0d5f404 100644 Binary files a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-android-darwin.png and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-android-darwin.png differ diff --git a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-android-landscape-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-android-landscape-darwin.png index 9d071951e1..999ea58329 100644 Binary files a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-android-landscape-darwin.png and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-android-landscape-darwin.png differ diff --git a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-ios-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-ios-darwin.png index e213dc044c..7e5e92894c 100644 Binary files a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-ios-darwin.png and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-ios-darwin.png differ diff --git a/packages/special-pages/tests/duckplayer.spec.js b/packages/special-pages/tests/duckplayer.spec.js index c7e01c233c..966e53587d 100644 --- a/packages/special-pages/tests/duckplayer.spec.js +++ b/packages/special-pages/tests/duckplayer.spec.js @@ -166,6 +166,26 @@ test.describe('duckplayer mobile settings', () => { }) }) +/** + * Use this test in `--headed` mode to cycle through every language + */ +test.describe('translated DuckPlayer UI', () => { + test.skip('testing UI for locales', async ({ page }, workerInfo) => { + 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'] + for (const locale of items) { + const duckplayer = DuckPlayerPage.create(page, workerInfo) + await duckplayer.openWithVideoID() + await duckplayer.hasLoadedIframe() + const params = new URLSearchParams({ + locale, + videoID: 'VIDEO_ID' + }) + await duckplayer.openPage(params) + await page.pause() + } + }) +}) + test.describe('duckplayer desktop settings', () => { test('always open setting', async ({ page }, workerInfo) => { test.skip(isMobile(workerInfo)) diff --git a/packages/special-pages/types/duckplayer.ts b/packages/special-pages/types/duckplayer.ts index 8e1268ca8f..07d8036787 100644 --- a/packages/special-pages/types/duckplayer.ts +++ b/packages/special-pages/types/duckplayer.ts @@ -88,6 +88,10 @@ export interface InitialSetupResponse { platform: { name: "macos" | "windows" | "android" | "ios"; }; + /** + * Optional locale-specific strings + */ + localeStrings?: string; } export interface DuckPlayerPageSettings { pip: { 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/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",