diff --git a/packages/special-pages/index.mjs b/packages/special-pages/index.mjs index 068befc7c..07d194323 100644 --- a/packages/special-pages/index.mjs +++ b/packages/special-pages/index.mjs @@ -143,7 +143,6 @@ for (const buildJob of buildJobs) { format: 'iife', // external: ['../assets/img/*'], sourcemap: NODE_ENV === 'development', - // publicPath: '/js', loader: { '.js': 'jsx', '.module.css': 'local-css', diff --git a/packages/special-pages/package.json b/packages/special-pages/package.json index 6259edee7..2d15f171c 100644 --- a/packages/special-pages/package.json +++ b/packages/special-pages/package.json @@ -11,9 +11,11 @@ "test": "npm run test.unit && playwright test", "test.windows": "npm run test -- --project windows", "test.macos": "npm run test -- --project macos", + "test.ios": "npm run test -- --project ios", + "test.android": "npm run test -- --project android", "test.headed": "npm run test -- --headed", "test.ui": "npm run test -- --ui", - "test.unit": "node --test unit-test/* ", + "test.unit": "node --test unit-test/* pages/duckplayer/unit-tests/* ", "pretest": "npm run build.dev", "pretest.headed": "npm run build.dev", "test-int-x": "npm run test", diff --git a/packages/special-pages/pages/duckplayer/app/base.css b/packages/special-pages/pages/duckplayer/app/base.css new file mode 100644 index 000000000..df34ee6e5 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/base.css @@ -0,0 +1,35 @@ +*, *:after, *:before { + box-sizing: border-box +} +html[data-reduced-motion=true] *:not([data-allow-animation]) { + animation: none!important; + transition: none!important; +} +/* Base styles */ +body { + font-family: system-ui, sans-serif; + font-size: 13px; + margin: 0; + + height: 100vh; + width: 100%; + overflow-x: hidden; + + /* Make it feel more like something native */ + user-select: none; + -webkit-user-select: none; + cursor: default; +} +button { + font-family: system-ui, sans-serif; + font-size: 13px; +} +ul { + margin: 0; + padding: 0; +} +li { + list-style: none; + margin: 0; + padding: 0; +} diff --git a/packages/special-pages/pages/duckplayer/app/components/App.jsx b/packages/special-pages/pages/duckplayer/app/components/App.jsx new file mode 100644 index 000000000..cd1a3f427 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/App.jsx @@ -0,0 +1,199 @@ +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 ( + <> + <Background /> + {features.focusMode()} + <main class={styles.app}> + {layout === 'desktop' && <DesktopLayout embed={embed} />} + {layout === 'mobile' && <MobileLayout embed={embed} orientation={orientation} />} + </main> + </> + ) +} + +/** + * @param {object} props + * @param {import("../embed-settings.js").EmbedSettings|null} props.embed + */ +function DesktopLayout({embed}) { + return ( + <div class={styles.desktop}> + <PlayerContainer> + {embed === null && <PlayerError layout={'desktop'} kind={'invalid-id'} />} + {embed !== null && <Player src={embed.toEmbedUrl()} layout={'desktop'} />} + <HideInFocusMode style={"slide"}> + <InfoBarContainer> + <InfoBar embed={embed}/> + </InfoBarContainer> + </HideInFocusMode> + </PlayerContainer> + </div> + ) +} + +/** + * @param {object} props + * @param {ReturnType<useOrientation>} 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 ( + <div class={classes}> + {orientation === "portrait" && ( + <div class={styles.header}> + <HideInFocusMode> + <TopBar> + <Wordmark /> + </TopBar> + </HideInFocusMode> + </div> + )} + <div class={styles.wrapper}> + <div class={styles.main}> + <PlayerContainer inset={insetPlayer}> + <PlayerInternal inset={insetPlayer}> + {embed === null && <PlayerError layout={'mobile'} kind={'invalid-id'}/>} + {embed !== null && <Player src={embed.toEmbedUrl()} layout={'mobile'}/>} + {orientation === "portrait" && ( + <SwitchProvider> + <SwitchBarMobile platformName={platformName} /> + </SwitchProvider> + )} + </PlayerInternal> + </PlayerContainer> + </div> + {orientation === "landscape" && <LandscapeControls embed={embed} platformName={platformName}/>} + {orientation === "portrait" && <PortraitControls embed={embed} />} + </div> + </div> + ) +} + +/** + * How the controls are rendered in Portrait mode. + * @param {object} props + * @param {import("../embed-settings.js").EmbedSettings|null} props.embed + */ +function PortraitControls({embed}) { + return ( + <div className={styles.controls}> + <HideInFocusMode> + <BottomNavBar> + <FloatingBar inset> + <MobileFooter embed={embed}/> + </FloatingBar> + </BottomNavBar> + </HideInFocusMode> + </div> + ) +} + +/** + * 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 ( + <div className={styles.rhs}> + <div className={styles.header}> + <HideInFocusMode> + <TopBar> + <Wordmark /> + </TopBar> + </HideInFocusMode> + </div> + <div className={styles.controls}> + <HideInFocusMode> + <FloatingBar> + <MobileFooter embed={embed}/> + </FloatingBar> + </HideInFocusMode> + </div> + <div className={styles.switch}> + <SwitchProvider> + <SwitchBarMobile platformName={platformName}/> + </SwitchProvider> + </div> + </div> + ) +} + +/** + * @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 ( + <> + <Button + icon={true} + buttonProps={{ + "aria-label": t('openInfoButton'), + onClick: openInfo + }} + ><Icon src={info}/></Button> + <Button + icon={true} + buttonProps={{ + "aria-label": t('openSettingsButton'), + onClick: openSettings + }} + ><Icon src={cog}/></Button> + <Button fill={true} + buttonProps={{ + onClick: () => { + if (embed) openOnYoutube(embed) + } + }} + >{t('watchOnYoutube')}</Button> + </> + ) +} + + diff --git a/packages/special-pages/pages/duckplayer/app/components/App.module.css b/packages/special-pages/pages/duckplayer/app/components/App.module.css new file mode 100644 index 000000000..47fa6de29 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/App.module.css @@ -0,0 +1,141 @@ +:root { + /* Set video to take up 80vw width */ + --video-width: 80vw; +} + +@media screen and (max-width: 1080px) { + :root { + --video-width: 85vw; + } +} + +@media screen and (max-width: 740px) { + :root { + --video-width: 90vw; + } +} + +:root [data-layout="desktop"] { + --frame-height: min( + calc(var(--video-width) * calc(9 / 16)), + 80vh + ) +} +:root [data-layout="mobile"][data-orientation="portrait"] { + --video-width: calc(100vw - 32px) +} +:root [data-layout="mobile"][data-orientation="landscape"] { + --video-width: calc(calc(100vw - 32px) * 0.6) /* 60% of the container */ +} +@media screen and (max-width: 700px) { + :root [data-layout="mobile"][data-orientation="landscape"] { + --video-width: calc(calc(100vw - 32px) * 0.5) /* 60% of the container */ + } +} + +:root [data-layout="mobile"] { + --frame-height: min( + calc(var(--video-width) * calc(9 / 16)), + calc(100vh - 32px) + ) +} + +.app { + margin: 0 auto; + position: relative; + z-index: 1; + height: 100%; + width: 100%; + max-width: 3840px; + color: rgba(255, 255, 255, 0.85); +} + +.portrait { + height: 100%; + display: grid; + align-self: center; + grid-template-areas: + 'header' + 'main'; + grid-template-rows: max-content 1fr; +} + +.landscape { + height: 100%; + display: grid; + align-self: center; + align-items: center; + align-content: center; +} + +.wrapper {} + +.portrait .wrapper { + grid-area: main; + display: grid; + grid-template-areas: + 'main' + 'controls'; + grid-template-rows: auto max-content; +} + +.landscape .wrapper { + display: grid; + grid-template-columns: 60% 1fr; + grid-column-gap: 8px; + background: rgba(0, 0, 0, 0.3); + border-radius: 16px; + padding: 8px; + @media screen and (max-width: 700px) { + grid-template-columns: 50% 1fr; + } +} + +.desktop { + height: 100%; + width: var(--video-width); + margin: 0 auto; + display: flex; + flex-direction: column; + justify-content: center; +} + +.rhs { + display: grid; + height: 100%; + grid-template-areas: 'header' 'controls' 'switch'; + grid-template-rows: max-content max-content auto; + grid-template-columns: 1fr; + grid-row-gap: 12px; +} + +/* When the RHS has a checked checkbox, we can center the other content */ +.rhs:has([data-state=completed] [aria-checked="true"]) { + align-content: center; +} + +.header { + grid-area: header; + padding-top: 48px; + @media screen and (max-height: 500px) { + padding-top: 32px; + } +} + +.main { + align-self: center; +} + +.controls { + grid-area: controls +} +.switch { + grid-area: switch +} + +.landscape .header { + padding-top: 8px; +} +.landscape .switch { + align-self: end; +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Background.jsx b/packages/special-pages/pages/duckplayer/app/components/Background.jsx new file mode 100644 index 000000000..76f7bf242 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Background.jsx @@ -0,0 +1,8 @@ +import { h } from "preact"; +import styles from "./Background.module.css"; + +export function Background() { + return ( + <div class={styles.bg} /> + ) +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Background.module.css b/packages/special-pages/pages/duckplayer/app/components/Background.module.css new file mode 100644 index 000000000..47f0ede6a --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Background.module.css @@ -0,0 +1,52 @@ +.bg { + background: url('../img/player-bg.jpg'); + background-size: cover; +} + +[data-layout="mobile"] .bg { + background: url('../img/mobile-bg.jpg'); + background-size: cover; +} + +.bg { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + width: 100%; + height: 100%; +} + +.bg::before { + content: ''; + position: absolute; + inset: 0; + height: 100%; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.00) 0%, rgba(0, 0, 0, 0.48) 32.23%, #000 93.87%); + transition: all .3s ease-in-out; +} + +.bg::after { + content: ''; + position: absolute; + inset: 0; + height: 100%; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.48) 0%, rgba(0, 0, 0, 0.90) 34.34%, #000 100%); + opacity: 0; + visibility: hidden; + transition: all .3s ease-in-out; +} + +[data-focus-mode="on"] .bg::before { + transition-delay: .1s; + opacity: 0; +} +[data-focus-mode="off"] .bg::after { + transition-delay: .1s; +} + +[data-focus-mode="on"] .bg::after { + opacity: 1; + visibility: visible; +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Button.jsx b/packages/special-pages/pages/duckplayer/app/components/Button.jsx new file mode 100644 index 000000000..3099e5a58 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Button.jsx @@ -0,0 +1,82 @@ +import {h} from "preact" +import cn from "classnames" +import styles from "./Button.module.css"; + +/** + * + * @param {object} props + * @param {import("preact").ComponentChild} props.children + * @param {"mobile" | "desktop"} [props.formfactor] + * @param {boolean} [props.icon] + * @param {boolean} [props.fill] + * @param {boolean} [props.highlight] + * @param {import("preact").ComponentProps<"button">} [props.buttonProps] + */ +export function Button({ + children, + formfactor = "mobile", + icon = false, + fill = false, + highlight = false, + buttonProps = {} + }) { + const classes = cn({ + [styles.button]: true, + [styles.desktop]: formfactor === "desktop", + [styles.highlight]: highlight === true, + [styles.fill]: fill === true, + [styles.iconOnly]: icon === true, + }) + return ( + <button + class={classes} + type="button" + {...buttonProps} + > + {children} + </button> + ) +} + +/** + * @param {object} props + * @param {import("preact").ComponentChild} props.children + * @param {"mobile" | "desktop"} [props.formfactor] + * @param {boolean} [props.icon] + * @param {boolean} [props.fill] + * @param {boolean} [props.highlight] + * @param {import("preact").ComponentProps<"a">} [props.anchorProps] + */ +export function ButtonLink({ + children, + formfactor = "mobile", + icon = false, + fill = false, + highlight = false, + anchorProps = {} + }) { + const classes = cn({ + [styles.button]: true, + [styles.desktop]: formfactor === "desktop", + [styles.highlight]: highlight === true, + [styles.fill]: fill === true, + [styles.iconOnly]: icon === true, + }) + return ( + <a + class={classes} + type="button" + {...anchorProps} + > + {children} + </a> + ) +} + +export function Icon({ src }) { + return ( + <span class={styles.icon}> + <img src={src} alt=""/> + </span> + ) +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Button.module.css b/packages/special-pages/pages/duckplayer/app/components/Button.module.css new file mode 100644 index 000000000..0d53c8482 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Button.module.css @@ -0,0 +1,79 @@ +.button { + border: none; + outline: none; + display: flex; + height: 44px; + line-height: 44px; + font-size: 16px; + padding: 0 20px; + flex-shrink: 0; + box-shadow: none; + background: rgba(255, 255, 255, 0.12); + border-radius: 8px; + font-weight: bold; + color: rgba(255, 255, 255, 1); + text-decoration: none; +} + +.button:hover, .button:focus-visible { + cursor: pointer; + background: rgba(255, 255, 255, 0.2); +} + +.fill { + flex: 1; + text-align: center; + justify-content: center; + padding-left: 4px; + padding-right: 4px; + text-wrap: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.desktop { + height: 32px; + line-height: 32px; + font-size: 13px; + font-weight: 600; + + .icon { + width: 16px; + height: 16px; + } +} + +.iconOnly { + width: 44px; + padding: 0 8px; + position: relative; +} + +.iconOnly .icon { + position: absolute; + top: 50%; + left: 50%; + transform: translateY(-50%) translateX(-50%); +} + +.icon {} +.icon img { + display: block; + width: 100%; +} + +.desktop.iconOnly { + width: 32px; +} + +.highlight { + transition: all .3s ease-in-out; + transition-delay: 2s; + background: rgba(255, 255, 255, 0.2); +} + +.highlight .icon img { + transition: all .3s ease-in-out; + transition-delay: 2s; + transform: scale(1.1); +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Components.jsx b/packages/special-pages/pages/duckplayer/app/components/Components.jsx new file mode 100644 index 000000000..42040b2a6 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Components.jsx @@ -0,0 +1,96 @@ +import { Fragment, h } from "preact"; +import styles from "./Components.module.css"; +import { PlayerContainer, PlayerInternal } from "./PlayerContainer.jsx"; +import info from '../img/info.data.svg'; +import cog from "../img/cog.data.svg"; +import { Button, Icon } from "./Button.jsx"; +import { FloatingBar } from "./FloatingBar.jsx"; +import { SwitchBarMobile } from "./SwitchBarMobile.jsx"; +import { InfoBar, InfoBarContainer, InfoIcon } from "./InfoBar.jsx"; +import { Wordmark } from "./Wordmark.jsx"; +import { Background } from "./Background.jsx"; +import { Player, PlayerError } from "./Player.jsx"; +import { SettingsProvider } from "../providers/SettingsProvider.jsx"; +import { Settings } from "../settings.js"; +import { EmbedSettings } from "../embed-settings.js"; +import { SwitchBarDesktop } from "./SwitchBarDesktop.jsx"; +import { SwitchProvider } from "../providers/SwitchProvider.jsx"; + +export function Components() { + const settings = new Settings({ + platform: {name: "macos"}, + }); + let embed = EmbedSettings.fromHref('https://localhost?videoID=123') + let url = embed?.toEmbedUrl(); + if (!url) throw new Error('unreachable') + return ( + <> + <div data-layout="mobile"> + <Background /> + </div> + <main class={styles.main}> + <div class={styles.tube}> + <Wordmark/> + <h2>Floating Bar</h2> + <div style="position: relative; padding-left: 10em; min-height: 150px;"> + <InfoIcon debugStyles={true} /> + </div> + + <h2>Info Tooltip</h2> + + <FloatingBar> + <Button icon={true}><Icon src={info}/></Button> + <Button icon={true}><Icon src={cog}/></Button> + <Button fill={true}>Open in YouTube</Button> + </FloatingBar> + + <h2>Info Bar</h2> + <SettingsProvider settings={settings}> + <SwitchProvider> + <InfoBar embed={embed}/> + </SwitchProvider> + </SettingsProvider> + <br/> + + <h2>Mobile Switch Bar (ios)</h2> + <SwitchProvider> + <SwitchBarMobile platformName={"ios"}/> + </SwitchProvider> + + <h2>Mobile Switch Bar (android)</h2> + <SwitchProvider> + <SwitchBarMobile platformName={"android"}/> + </SwitchProvider> + + <h2>Desktop Switch bar</h2> + <h3>idle</h3> + <SwitchProvider> + <SwitchBarDesktop /> + </SwitchProvider> + + </div> + <h2><code>inset=false (desktop)</code></h2> + <SettingsProvider settings={settings}> + <PlayerContainer> + <Player src={url} layout={'desktop'} /> + <InfoBarContainer> + <InfoBar embed={embed} /> + </InfoBarContainer> + </PlayerContainer> + </SettingsProvider> + <br/> + + <h2><code>inset=true (mobile)</code></h2> + <PlayerContainer inset> + <PlayerInternal inset> + <PlayerError layout={'mobile'} kind={'invalid-id'} /> + <SwitchBarMobile platformName={"ios"} /> + </PlayerInternal> + </PlayerContainer> + + <br/> + + </main> + </> + ) +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Components.module.css b/packages/special-pages/pages/duckplayer/app/components/Components.module.css new file mode 100644 index 000000000..9c0b4d88e --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Components.module.css @@ -0,0 +1,13 @@ +.main { + color: white; + max-width: 3840px; + margin: 0 auto; + padding: 2rem 8px; + position: relative; + z-index: 1; +} + +.tube { + max-width: 750px; + margin: 0 auto; +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Fallback.jsx b/packages/special-pages/pages/duckplayer/app/components/Fallback.jsx new file mode 100644 index 000000000..5555dbdcc --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Fallback.jsx @@ -0,0 +1,19 @@ +import { h } from "preact"; +import styles from "./Fallback.module.css"; + +/** + * @param {object} props + * @param {boolean} props.showDetails + */ +export function Fallback({showDetails}) { + return ( + <div class={styles.fallback}> + <div> + <p>Something went wrong!</p> + {showDetails && ( + <p>Please check logs for a message called <code>reportPageException</code></p> + )} + </div> + </div> + ) +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Fallback.module.css b/packages/special-pages/pages/duckplayer/app/components/Fallback.module.css new file mode 100644 index 000000000..6e689c17f --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Fallback.module.css @@ -0,0 +1,4 @@ +.fallback { + height: 100%; + width: 100%; +} diff --git a/packages/special-pages/pages/duckplayer/app/components/FloatingBar.jsx b/packages/special-pages/pages/duckplayer/app/components/FloatingBar.jsx new file mode 100644 index 000000000..0e5ea632e --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/FloatingBar.jsx @@ -0,0 +1,41 @@ +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 ( + <div class={styles.bottomNavBar}> + {children} + </div> + ) +} + +/** + * @param {object} props + * @param {import("preact").ComponentChild} props.children + * @param {boolean} [props.inset] + */ +export function FloatingBar({children, inset = false}) { + return ( + <div class={cn(styles.floatingBar, {[styles.inset]: inset})}> + {children} + </div> + ) +} + +/** + * @param {object} props + * @param {import("preact").ComponentChild} props.children + */ +export function TopBar({children}) { + return ( + <div class={styles.topBar}> + {children} + </div> + ) +} diff --git a/packages/special-pages/pages/duckplayer/app/components/FloatingBar.module.css b/packages/special-pages/pages/duckplayer/app/components/FloatingBar.module.css new file mode 100644 index 000000000..5a244ad1b --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/FloatingBar.module.css @@ -0,0 +1,20 @@ +.floatingBar { + display: flex; + gap: 8px; +} + +.inset { + border-radius: 8px; + padding: 8px; + background: rgba(0, 0, 0, 0.3); +} + +.bottomNavBar { +} + + +.topBar { + display: grid; + justify-content: center; + width: 100%; +} diff --git a/packages/special-pages/pages/duckplayer/app/components/FocusMode.jsx b/packages/special-pages/pages/duckplayer/app/components/FocusMode.jsx new file mode 100644 index 000000000..e760f898d --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/FocusMode.jsx @@ -0,0 +1,76 @@ +import { h } from "preact"; +import cn from "classnames"; +import { useCallback, useEffect } from "preact/hooks"; +import styles from "./FocusMode.module.css"; + +export function FocusMode() { + useEffect(() => { + let enabled = true; + let timerId; + const on = () => { + if (document.documentElement.dataset.focusModeState === 'paused') { + // try again after delay + wait() + } else { + if (!enabled) return; + document.documentElement.dataset.focusMode = 'on' + } + } + const off = () => document.documentElement.dataset.focusMode = 'off' + const cancel = () => { + clearTimeout(timerId); + off() + wait() + } + const wait = () => { + clearTimeout(timerId) + timerId = setTimeout(on, 2000) + } + + // start + wait() + + // event listeners on the top document + document.addEventListener('mousemove', cancel) + document.addEventListener('pointerdown', cancel) + + // other events that might occur + window.addEventListener('frame-mousemove', cancel) + window.addEventListener('ddg-duckplayer-focusmode-off', () => { + enabled = false; + off() + }) + + return () => { + clearTimeout(timerId); + } + }, []) + return null +} + +/** + * Hides the content in focus mode. + * + * @param {Object} props - The input props. + * @param {import("preact").ComponentChild} props.children - The content to be hidden. + * @param {"fade" | "slide"} [props.style="fade"] - The style for hiding the content. + */ +export function HideInFocusMode({ children, style="fade"}) { + const classes = cn({ + [styles.hideInFocus]: true, + [styles.fade]: style==="fade", + [styles.slide]: style==="slide", + }) + return ( + <div class={classes} data-style={style}>{children}</div> + ) +} + +/** + * Allow a mechanism for pausing focus mode - for example, when a tooltip is open + */ +export function useSetFocusMode() { + return useCallback((/** @type {'enabled' | 'disabled' | 'paused'} */action) => { + document.documentElement.dataset.focusModeState = action + }, []) +} diff --git a/packages/special-pages/pages/duckplayer/app/components/FocusMode.module.css b/packages/special-pages/pages/duckplayer/app/components/FocusMode.module.css new file mode 100644 index 000000000..5c061950a --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/FocusMode.module.css @@ -0,0 +1,17 @@ +[data-focus-mode="on"] .fade { + opacity: 0; + visibility: hidden; +} + +[data-focus-mode="on"] .slide { + opacity: 0; + visibility: hidden; + transform: translateY(-100%); +} + +.hideInFocus { + opacity: 1; + visibility: visible; + top: 0; + transition: all .3s; +} diff --git a/packages/special-pages/pages/duckplayer/app/components/InfoBar.jsx b/packages/special-pages/pages/duckplayer/app/components/InfoBar.jsx new file mode 100644 index 000000000..90aba9f34 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/InfoBar.jsx @@ -0,0 +1,145 @@ +import styles from "./InfoBar.module.css" +import { h } from "preact"; +import dax from "../img/dax.data.svg" +import info from "../img/info.data.svg" +import cog from "../img/cog.data.svg" +import { Button, ButtonLink, Icon } from "./Button.jsx"; +import { SwitchBarDesktop } from "./SwitchBarDesktop.jsx"; +import { useOpenOnYoutubeHandler, useSettingsUrl } from "../providers/SettingsProvider.jsx"; +import { useContext, useLayoutEffect, useRef, useState } from "preact/hooks"; +import { SwitchContext, SwitchProvider } from "../providers/SwitchProvider.jsx"; +import { Tooltip } from "./Tooltip.jsx"; +import { useSetFocusMode } from "./FocusMode.jsx"; +import { useTypedTranslation } from "../types.js"; + +/** + * @param {object} props + * @param {import("../embed-settings.js").EmbedSettings|null} props.embed + */ +export function InfoBar({embed}) { + return ( + <div class={styles.infoBar}> + <div class={styles.lhs}> + <div class={styles.dax}> + <img src={dax} class={styles.img}/> + </div> + <div class={styles.text}> + Duck Player + </div> + <InfoIcon /> + </div> + <div class={styles.rhs}> + <SwitchProvider> + <div class={styles.switch}> + <SwitchBarDesktop /> + </div> + <ControlBarDesktop embed={embed} /> + </SwitchProvider> + </div> + </div> + ) +} + +/** + * @param {object} props + * @param {boolean} [props.debugStyles] + */ +export function InfoIcon({ debugStyles = false }) { + const setFocusMode = useSetFocusMode(); + const [isVisible, setIsVisible] = useState(debugStyles); + const [isBottom, setIsBottom] = useState(false); + /** + * @type {import("preact/hooks").MutableRef<HTMLButtonElement|null>} + */ + const tooltipRef = useRef(null); + + function show() { + setIsVisible(true) + setFocusMode('paused') + } + function hide() { + setIsVisible(false) + setFocusMode('enabled') + } + + useLayoutEffect(() => { + if (!tooltipRef.current) return; + const icon = tooltipRef.current; + const rect = icon.getBoundingClientRect() + + const iconTop = rect.top + window.scrollY + const spaceBelowIcon = window.innerHeight - iconTop + + if (spaceBelowIcon < 125) { + return setIsBottom(false) + } + + return setIsBottom(true) + }, [isVisible]); + + return ( + <button className={styles.info} + aria-describedby="tooltip1" + aria-expanded={isVisible} + aria-label="Info" + onMouseEnter={show} + onMouseLeave={hide} + onFocus={show} // for keyboard accessibility + onBlur={hide} // for keyboard accessibility + ref={tooltipRef} + > + <Icon src={info}/> + <Tooltip + id="tooltip1" + isVisible={isVisible} + position={isBottom ? 'bottom' : 'top'} + /> + </button> + ) +} + +/** + * @param {object} props + * @param {import("../embed-settings.js").EmbedSettings|null} props.embed + */ +function ControlBarDesktop({embed}) { + const settingsUrl = useSettingsUrl() + const openOnYoutube = useOpenOnYoutubeHandler(); + const {t} = useTypedTranslation(); + const { state } = useContext(SwitchContext); + return ( + <div className={styles.controls}> + <ButtonLink + formfactor={"desktop"} + icon={true} + highlight={state === 'exiting'} + anchorProps={{ + "href": settingsUrl, + target: '_blank', + "aria-label": t('openSettingsButton') + }} + ><Icon src={cog}/></ButtonLink> + <Button + formfactor={"desktop"} + buttonProps={{ + onClick: () => { + if (embed) openOnYoutube(embed) + } + }} + >Watch on YouTube</Button> + </div> + ) +} + +/** + * @param {object} props + * @param {import("preact").ComponentChild} props.children + */ +export function InfoBarContainer({children}) { + return ( + <div class={styles.container}> + {children} + </div> + ) +} + diff --git a/packages/special-pages/pages/duckplayer/app/components/InfoBar.module.css b/packages/special-pages/pages/duckplayer/app/components/InfoBar.module.css new file mode 100644 index 000000000..05c700101 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/InfoBar.module.css @@ -0,0 +1,82 @@ +.infoBar { + display: flex; + align-items: center; + gap: 8px; + flex: 1; +} + +.container { + padding: 12px; + background: rgba(0, 0, 0, 0.3); + position: relative; + z-index: 1; + border-bottom-left-radius: 14px; + border-bottom-right-radius: 14px; +} + +.dax { + padding: 2px; +} + +.img { + display: block; + width: 20px; + height: 20px; +} + +.text { + margin-left: 5px; + margin-right: 8px; + font-size: 14px; + font-weight: bold; + white-space: nowrap; +} + +.info { + width: 16px; + height: 16px; + appearance: none; + -webkit-appearance: none; + background: none; + border: 0; + padding: 0; + margin: 0; + position: relative; + + + &:focus-visible { + outline: none; + } + + &:focus-visible[aria-expanded=true] { + outline: 1px solid white; + outline-offset: 2px; + border-radius: 50%; + } + + img { + display: block; + width: 100%; + } +} +.lhs { + display: flex; + align-items: center; +} +.rhs { + margin-left: auto; + display: flex; + gap: 16px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} +.switch { + display: flex; + overflow: hidden; + white-space: nowrap; +} +.controls { + display: flex; + gap: 8px; +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Player.jsx b/packages/special-pages/pages/duckplayer/app/components/Player.jsx new file mode 100644 index 000000000..fec21969a --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Player.jsx @@ -0,0 +1,131 @@ +import { h } from "preact"; +import cn from "classnames"; +import styles from "./Player.module.css" +import { useEffect, useRef } from "preact/hooks"; +import { useSettings } from "../providers/SettingsProvider.jsx"; +import { createIframeFeatures } from "../features/iframe.js"; +import { Settings } from "../settings"; +import { useTypedTranslation } from "../types.js"; + +/** + * Player component renders an embedded media player. + * + * @param {object} props + * @param {string} props.src - The source URL of the media to be played. + * @param {Settings['layout']} props.layout + */ +export function Player({ src, layout }) { + const {ref, didLoad} = useIframeEffects(src); + return ( + <div class={cn({ + [styles.root]: true, + [styles.player]: true, + [styles.desktop]: layout === 'desktop', + [styles.mobile]: layout === 'mobile', + })}> + <iframe + class={styles.iframe} + frameBorder="0" + id="player" + allow="autoplay; encrypted-media; fullscreen" + sandbox="allow-popups allow-scripts allow-same-origin allow-popups-to-escape-sandbox" + src={src} + ref={ref} + onLoad={didLoad} + /> + </div> + ) +} + +/** + * @param {object} props + * @param {Settings['layout']} props.layout + * @param {'invalid-id'} props.kind + */ +export function PlayerError({ kind, layout }) { + const { t } = useTypedTranslation(); + const errors = { + ['invalid-id']: <span dangerouslySetInnerHTML={{__html: t('invalidIdError') }} /> + } + const text = errors[kind] || errors['invalid-id']; + return ( + <div class={cn(styles.root, { + [styles.desktop]: layout === 'desktop', + [styles.mobile]: layout === 'mobile', + })}> + <div className={styles.error}> + <p> + {text} + </p> + </div> + </div> + ) +} + +/** + * This is used to track the lifecycle of an iframe, and apply + * a known list of features to it once it's loaded. + * + * We use 2 ways to detect if the iframe is 'ready'. + * 1: we try an `iframe.addEventListener('load', loadHandler);` + * 2: we look if it already loaded (can happen with caching) via an onLoad handler + * in the parent that called this. + * + * When either event occurs, we proceed to apply our list of features. + * + * @param {string} src - the iframe `src` attribute + * @return {{ + * ref: import("preact/hooks").MutableRef<HTMLIFrameElement|null>, + * didLoad: () => void + * }} + */ +function useIframeEffects(src) { + + const ref = useRef(/** @type {HTMLIFrameElement|null} */(null)) + const didLoad = useRef(/** @type {boolean} */(false)) + const settings = useSettings(); + + useEffect(() => { + if (!ref.current) return; + const iframe = ref.current; + const features = createIframeFeatures(settings); + + /** @type {import("../features/iframe.js").IframeFeature[]} */ + const iframeFeatures = [ + features.autofocus(), + features.pip(), + features.clickCapture(), + features.titleCapture(), + features.mouseCapture(), + ] + + /** + * @type {ReturnType<import("../features/pip").IframeFeature['iframeDidLoad']>[]} + */ + const cleanups = []; + const loadHandler = () => { + for (let feature of iframeFeatures) { + try { + cleanups.push(feature.iframeDidLoad(iframe)) + } catch (e) { + console.error(e) + } + } + }; + + if (didLoad.current === true) { + loadHandler() + } else { + iframe.addEventListener('load', loadHandler); + } + + return () => { + for (let cleanup of cleanups) { + cleanup?.() + } + iframe.removeEventListener('load', loadHandler); + } + }, [src, settings]); + + return { ref, didLoad: () => didLoad.current = true }; +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Player.module.css b/packages/special-pages/pages/duckplayer/app/components/Player.module.css new file mode 100644 index 000000000..116488cf5 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Player.module.css @@ -0,0 +1,32 @@ +.root { + z-index: 1; + position: relative; + overflow: hidden; + 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: var(--frame-height); + width: 100%; + z-index: 1; + position: relative; +} + +.error { + height: 100%; + display: grid; + align-items: center; + justify-items: center; + background: #2f2f2f; +} diff --git a/packages/special-pages/pages/duckplayer/app/components/PlayerContainer.jsx b/packages/special-pages/pages/duckplayer/app/components/PlayerContainer.jsx new file mode 100644 index 000000000..3465c2dee --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/PlayerContainer.jsx @@ -0,0 +1,34 @@ +import { h } from "preact"; +import cn from "classnames"; +import styles from "./PlayerContainer.module.css"; + + +/** + * Creates a container for player elements. + * + * @param {Object} props - The properties for the PlayerContainer component. + * @param {import("preact").ComponentChild} props.children - The child elements to render inside the container. + * @param {boolean} [props.inset] - whether the UI is all inset + */ +export function PlayerContainer({ children, inset }) { + return ( + <div class={cn(styles.container, { + [styles.inset]: inset + })}> + {children} + </div> + ) +} + +/** + * Creates a container for player elements. + * + * @param {Object} props - The properties for the PlayerContainer component. + * @param {import("preact").ComponentChild} props.children - The child elements to render inside the container. + * @param {boolean} [props.inset] - whether the UI is all inset + */ +export function PlayerInternal({children, inset}) { + return <div class={cn(styles.internals, {[styles.insetInternals]: inset})}> + {children} + </div> +} diff --git a/packages/special-pages/pages/duckplayer/app/components/PlayerContainer.module.css b/packages/special-pages/pages/duckplayer/app/components/PlayerContainer.module.css new file mode 100644 index 000000000..235bfe69e --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/PlayerContainer.module.css @@ -0,0 +1,23 @@ +.container { + border-radius: 14px; +} + +.inset { + padding: 8px; + border-radius: 16px; + background: rgba(0, 0, 0, 0.3); + transition: background 1s; +} + +[data-focus-mode=on] .inset { + background: none; +} + +.internals { + display: grid; + overflow: hidden; +} + +.insetInternals { + gap: 8px; +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Switch.jsx b/packages/special-pages/pages/duckplayer/app/components/Switch.jsx new file mode 100644 index 000000000..2b53ffdb3 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Switch.jsx @@ -0,0 +1,26 @@ +import styles from "./Switch.module.css"; +import { h } from "preact"; +import cn from "classnames"; + +/** + * Renders a switch component with given checked, id and onChange props. + * + * @param {object} props - The component props. + * @param {boolean} props.checked - Indicates whether the switch is checked or not. + * @param {() => void} props.onChange - The callback function to be called when the switch is toggled. + * @param {ImportMeta['platform']} props.platformName - The callback function to be called when the switch is toggled. + */ +export function Switch({ checked, onChange, platformName = 'ios' }) { + return ( + <button role="switch" + aria-checked={checked} + onClick={onChange} + className={cn(styles.switch, { + [styles.ios]: platformName === 'ios', + [styles.android]: platformName === 'android', + })} + > + <span className={styles.thumb}></span> + </button> + ) +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Switch.module.css b/packages/special-pages/pages/duckplayer/app/components/Switch.module.css new file mode 100644 index 000000000..3a6e726aa --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Switch.module.css @@ -0,0 +1,58 @@ +.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: 24px; + height: 24px; + border-radius: 100%; + background: white; + position: absolute; + top: 4px; + left: 4px; + pointer-events: none; + transition: .2s left ease-in-out; +} + +.switch[aria-checked="true"] .thumb { + left: calc(100% - 32px + 4px) +} +.switch[aria-checked="true"] { + background: rgba(57, 105, 239, 1) +} + +.ios { + width: 51px; + height: 31px; +} + +.ios .thumb { + top: 2px; + left: 2px; + width: 27px; + height: 27px; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.25) +} + +.ios:active .thumb { + scale: 1; +} + +.ios[aria-checked="true"] .thumb { + left: calc(100% - 32px + 3px) +} + +.android {} diff --git a/packages/special-pages/pages/duckplayer/app/components/SwitchBar.module.css b/packages/special-pages/pages/duckplayer/app/components/SwitchBar.module.css new file mode 100644 index 000000000..f8047cd6a --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/SwitchBar.module.css @@ -0,0 +1,60 @@ +.switchBar { + display: flex; + gap: 8px; + border-radius: 8px; + padding: 8px 12px; + 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; +} + +[data-focus-mode="on"] .switchBar { + opacity: 0; + visibility: hidden; +} + +.stateExiting { + + transition: all .3s ease-in-out; + transition-delay: 2s; + opacity: 0; + visibility: hidden; + transform: translateY(-50%); +} + +.stateHidden { + display: none; +} + +.label { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 16px; +} + +.checkbox {} + +.text { + font-weight: bold; + font-size: 14px; +} + +.placeholder { + height: 44px; + position: relative; + width: 100%; + > * { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } +} diff --git a/packages/special-pages/pages/duckplayer/app/components/SwitchBarDesktop.jsx b/packages/special-pages/pages/duckplayer/app/components/SwitchBarDesktop.jsx new file mode 100644 index 000000000..2f109377a --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/SwitchBarDesktop.jsx @@ -0,0 +1,45 @@ +import styles from "./SwitchBarDesktop.module.css"; +import { h } from "preact"; +import cn from "classnames"; +import { useContext, } from "preact/hooks"; +import { SwitchContext } from "../providers/SwitchProvider.jsx"; +import { useTypedTranslation } from "../types.js"; + +/** + * Renders the desktop version of the switch bar component. + */ +export function SwitchBarDesktop() { + const { onChange, onDone, state } = useContext(SwitchContext); + const { t } = useTypedTranslation(); + function blockClick(e) { + if (state === 'exiting') { + return e.preventDefault() + } + } + + const classes = cn({ + [styles.switchBarDesktop]: true, + [styles.stateExiting]: state === 'exiting', + [styles.stateCompleted]: state === 'completed', + }) + + return ( + <div class={classes} + data-state={state} + data-allow-animation={true} + onTransitionEnd={onDone}> + <label class={styles.label} onClick={blockClick}> + <span class={styles.checkbox}> + <input class={styles.input} + onChange={onChange} + name="enabled" + type="checkbox" + checked={state !== 'showing'} + /> + </span> + <span class={styles.text}>{t("alwaysWatchHere")}</span> + </label> + </div> + ) +} + diff --git a/packages/special-pages/pages/duckplayer/app/components/SwitchBarDesktop.module.css b/packages/special-pages/pages/duckplayer/app/components/SwitchBarDesktop.module.css new file mode 100644 index 000000000..696082d1f --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/SwitchBarDesktop.module.css @@ -0,0 +1,59 @@ +.switchBarDesktop { + display: flex; + align-items: center; +} + +.stateCompleted { + display: none; +} + +.stateExiting { + transition: all .5s ease-in-out; + transition-delay: 2s; + opacity: 0; + visibility: hidden; +} + +.label { + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; + overflow: hidden; +} + +.stateExiting .label { + animation: slide-out .5s forwards; + animation-delay: 2s; +} + + +@keyframes slide-out { + 0% { transform: translateX(0) } + 100% { transform: translateX(100%) } +} + +.checkbox { + display: block; +} + +.stateExiting .input { + pointer-events: none; +} + +.input { + display: block; + + &:focus-visible { + outline: 1px solid white; + outline-offset: 2px; + } +} + +.input[disabled] {} +.text { + line-height: 1; + &:hover { + cursor: pointer; + } +} diff --git a/packages/special-pages/pages/duckplayer/app/components/SwitchBarMobile.jsx b/packages/special-pages/pages/duckplayer/app/components/SwitchBarMobile.jsx new file mode 100644 index 000000000..4738902d3 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/SwitchBarMobile.jsx @@ -0,0 +1,52 @@ +import { h } from "preact"; +import cn from "classnames"; +import styles from "./SwitchBar.module.css" +import { useContext } from "preact/hooks"; +import { SwitchContext } from "../providers/SwitchProvider.jsx"; +import { Switch } from "./Switch.jsx"; +import { useTypedTranslation } from "../types.js"; + +/** + * Renders a switch bar component. + * + * @param {Object} props - The properties for the switch bar component. + * @param {ImportMeta['platform']} props.platformName - The name of the platform. + */ +export function SwitchBarMobile({platformName}) { + const {onChange, onDone, state} = useContext(SwitchContext); + const { t } = useTypedTranslation(); + + function blockClick(e) { + if (state === 'exiting') { + return e.preventDefault() + } + } + + function onTransitionEnd(e) { + // check it's the root element that's finished animating + if (e.target?.dataset?.state === 'exiting') { + onDone() + } + } + + const classes = cn({ + [styles.switchBar]: true, + [styles.stateExiting]: state === 'exiting', + [styles.stateHidden]: state === 'completed', + }); + + return ( + <div class={classes} data-state={state} onTransitionEnd={onTransitionEnd}> + <label onClick={blockClick} class={styles.label}> + <span className={styles.text}> + {t('keepEnabled')} + </span> + <Switch + checked={state !== 'showing'} + onChange={onChange} + platformName={platformName} + /> + </label> + </div> + ) +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Tooltip.jsx b/packages/special-pages/pages/duckplayer/app/components/Tooltip.jsx new file mode 100644 index 000000000..227885ddc --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Tooltip.jsx @@ -0,0 +1,24 @@ +import { h } from "preact"; +import cn from "classnames"; +import styles from "./Tooltip.module.css"; + +/** + * @param {object} props + * @param {string} props.id + * @param {boolean} props.isVisible + * @param {'top' | 'bottom'} props.position + */ +export function Tooltip({ id, isVisible, position }) { + return ( + <div class={cn(styles.tooltip, { + [styles.top]: position === 'top', + [styles.bottom]: position === 'bottom', + })} + role="tooltip" + aria-hidden={!isVisible} + id={id}> + Duck Player provides a clean viewing experience without personalized ads and prevents viewing activity from + influencing your YouTube recommendations. + </div> + ) +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Tooltip.module.css b/packages/special-pages/pages/duckplayer/app/components/Tooltip.module.css new file mode 100644 index 000000000..85a71e692 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Tooltip.module.css @@ -0,0 +1,53 @@ +.tooltip { + position: absolute; + background: linear-gradient(0deg, rgba(48, 48, 48, 0.35), rgba(48, 48, 48, 0.35)), rgba(33, 33, 33, 0.55); + background-blend-mode: normal, luminosity; + box-shadow: inset 0px 0px 1px #ffffff; + filter: drop-shadow(0px 0px 1px #000000) drop-shadow(0px 0px 1px #000000) drop-shadow(0px 0px 1px #000000) + drop-shadow(0px 8px 16px rgba(0, 0, 0, 0.2)); + backdrop-filter: blur(76px); + border-radius: 10px; + width: 300px; + font-weight: normal; + padding: 12px; + left: -162px; + top: 32px; + color: white; + display: none; + z-index: 1; +} + +.tooltip[aria-hidden=false] { + display: block ; +} + +.tooltip::after { + content: ''; + width: 15px; + height: 15px; + border: 1px solid #5f5f5f; + display: block; + position: absolute; + top: -8px; + border-right: none; + border-bottom: none; + transform: rotate(45deg); + background: #1d1d1d; + left: 162px; +} + +.top { + top: -100px; + z-index: 50; +} + +.top::after { + top: calc(100% - 7px); + transform: rotate(225deg); +} + +.bottom {} + +.tooltip.visible { + display: block; +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Wordmark.jsx b/packages/special-pages/pages/duckplayer/app/components/Wordmark.jsx new file mode 100644 index 000000000..d98977b1d --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Wordmark.jsx @@ -0,0 +1,16 @@ +import styles from "./Wordmark.module.css"; +import dax from "../img/dax.data.svg"; +import { h } from "preact"; + +export function Wordmark() { + return ( + <div class={styles.wordmark}> + <div className={styles.logo}> + <img src={dax} className={styles.img} alt="DuckDuckGo logo"/> + </div> + <div className={styles.text}> + Duck Player + </div> + </div> + ) +} diff --git a/packages/special-pages/pages/duckplayer/app/components/Wordmark.module.css b/packages/special-pages/pages/duckplayer/app/components/Wordmark.module.css new file mode 100644 index 000000000..d46636cf2 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/components/Wordmark.module.css @@ -0,0 +1,18 @@ +.wordmark { + display: flex; + white-space: nowrap; + align-items: center; + gap: 8px; +} +.logo { + width: 32px; + height: 32px; +} +.img { + display: block; + width: 100%; +} +.text { + font-size: 19px; + font-weight: bold; +} diff --git a/packages/special-pages/pages/duckplayer/app/embed-settings.js b/packages/special-pages/pages/duckplayer/app/embed-settings.js new file mode 100644 index 000000000..6085b3e13 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/embed-settings.js @@ -0,0 +1,258 @@ +export class EmbedSettings { + /** + * @param {object} params + * @param {VideoId} params.videoId - videoID is required + * @param {Timestamp|null|undefined} params.timestamp - optional timestamp + * @param {boolean} [params.autoplay] - optional timestamp + * @param {boolean} [params.muted] - optionally start muted + */ + constructor ({ + videoId, + timestamp, + autoplay = true, + muted = false + }) { + this.videoId = videoId + this.timestamp = timestamp + this.autoplay = autoplay + this.muted = muted + } + + /** + * @param {boolean|null|undefined} autoplay + * @return {EmbedSettings} + */ + withAutoplay (autoplay) { + if (typeof autoplay !== 'boolean') return this + return new EmbedSettings({ + ...this, + autoplay + }) + } + + /** + * @param {boolean|null|undefined} muted + * @return {EmbedSettings} + */ + withMuted (muted) { + if (typeof muted !== 'boolean') return this + return new EmbedSettings({ + ...this, + muted + }) + } + + /** + * @param {string|null|undefined} href + * @returns {EmbedSettings|null} + */ + static fromHref (href) { + try { + return new EmbedSettings({ + videoId: VideoId.fromHref(href), + timestamp: Timestamp.fromHref(href) + }) + } catch (e) { + console.error(e) + return null + } + } + + /** + * @return {string} + */ + toEmbedUrl () { + const url = new URL(`/embed/${this.videoId.id}`, 'https://www.youtube-nocookie.com') + + url.searchParams.set('iv_load_policy', '1') // show video annotations + + if (this.autoplay) { + url.searchParams.set('autoplay', '1') // autoplays the video as soon as it loads + + if (this.muted) { + url.searchParams.set('muted', '1') // certain platforms require this to be muted to autoplay + } + } + + url.searchParams.set('rel', '0') // shows related videos from the same channel as the video + url.searchParams.set('modestbranding', '1') // disables showing the YouTube logo in the video control bar + + if (this.timestamp && this.timestamp.seconds > 0) { + url.searchParams.set('start', String(this.timestamp.seconds)) // if timestamp supplied, start video at specific point + } + + return url.href + } + + /** + * @param {URL} base + * @return {string} + */ + intoYoutubeUrl (base) { + const url = new URL(base) + url.searchParams.set('v', this.videoId.id) + if (this.timestamp && this.timestamp.seconds > 0) { + url.searchParams.set('t', `${this.timestamp.seconds}s`) + } + return url.toString() + } +} + +/** + * Represents a valid ID. + */ +class VideoId { + /** + * @param {string|null|undefined} input + * @throws {Error} + */ + constructor (input) { + if (typeof input !== 'string') throw new Error('string required, got: ' + input) + const sanitized = sanitizeYoutubeId(input) + if (sanitized === null) throw new Error('invalid ID from: ' + input) + this.id = sanitized + } + + /** + * @param {string|null|undefined} href + */ + static fromHref (href) { + return new VideoId(idFromHref(href)) + } +} + +/** + * Represents a valid timestamp. + */ +class Timestamp { + /** + * @param {string|null|undefined} input + * @throws {Error} + */ + constructor (input) { + if (typeof input !== 'string') throw new Error('string required for timestamp') + const seconds = timestampInSeconds(input) + if (seconds === null) throw new Error('invalid input for timestamp: ' + input) + this.seconds = seconds + } + + /** + * @param {string|null|undefined} href + * @return {Timestamp|null} + */ + static fromHref (href) { + if (typeof href !== 'string') return null + const param = timestampFromHref(href) + if (param) { + try { + return new Timestamp(param) + } catch (e) { + return null + } + } + return null + } +} + +/** + * @param {string|null|undefined} href + * @return {string|null} + */ +function idFromHref (href) { + if (typeof href !== 'string') return null + + let url + + try { + url = new URL(href) + } catch (e) { + return null + } + + const fromParam = url.searchParams.get('videoID') + + if (fromParam) return fromParam + + if (url.protocol === 'duck:') { + return url.pathname.slice(1) + } + + if (url.pathname.includes('/embed/')) { + return url.pathname.replace('/embed/', '') + } + + return null +} + +/** + * @param {string|null|undefined} href + * @return {string|null} + */ +function timestampFromHref (href) { + if (typeof href !== 'string') return null + + let url + + try { + url = new URL(href) + } catch (e) { + console.error(e) + return null + } + + const timeParameter = url.searchParams.get('t') + + if (timeParameter) { + return timeParameter + } + + return null +} + +/** + * Converts a timestamp in the format "<number><optional letter>" to the number of seconds. + * + * @param {string} timestamp - The timestamp to convert. + * @return {number | null} - The number of seconds in the timestamp, or null if the timestamp is invalid. + */ +function timestampInSeconds (timestamp) { + const units = { + h: 3600, + m: 60, + s: 1 + } + + const parts = timestamp.split(/(\d+[hms]?)/) + + const totalSeconds = parts.reduce((total, part) => { + if (!part) return total + + for (const unit in units) { + if (part.includes(unit)) { + return total + (parseInt(part) * units[unit]) + } + } + + return total + }, 0) + + if (totalSeconds > 0) { + return totalSeconds + } + + return null +} + +/** + * Sanitizes an input string by removing characters that are not alphanumeric, hyphen, or underscore. + * + * @param {string} input - The input string to be sanitized. + * @return {string|null} - The sanitized string or null if the input string contains invalid characters. + */ +function sanitizeYoutubeId (input) { + const subject = input.slice(0, 11) + if (/^[a-zA-Z0-9-_]+$/.test(subject)) { + return subject + } + return null +} diff --git a/packages/special-pages/pages/duckplayer/app/features/app.js b/packages/special-pages/pages/duckplayer/app/features/app.js new file mode 100644 index 000000000..fbfe8c51b --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/features/app.js @@ -0,0 +1,17 @@ +import { FocusMode } from '../components/FocusMode.jsx' +import { h } from 'preact' + +/** + * @param {import("../settings.js").Settings} settings + */ +export function createAppFeaturesFrom (settings) { + return { + focusMode: () => { + if (settings.focusMode.state === 'enabled') { + return <FocusMode /> + } else { + return null + } + } + } +} diff --git a/packages/special-pages/pages/duckplayer/app/features/autofocus.js b/packages/special-pages/pages/duckplayer/app/features/autofocus.js new file mode 100644 index 000000000..d9974a0dd --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/features/autofocus.js @@ -0,0 +1,47 @@ +/** + * @typedef {import("./iframe").IframeFeature} IframeFeature + */ + +/** + * @implements IframeFeature + */ +export class AutoFocus { + /** + * @param {HTMLIFrameElement} iframe + */ + iframeDidLoad (iframe) { + const maxAttempts = 1000 + let attempt = 0 + let id + + function check () { + if (!iframe.contentDocument) return + if (attempt > maxAttempts) return + + attempt += 1 + + const selector = '#player video' + + // try to select the video element + const video = /** @type {HTMLIFrameElement | null} */(iframe.contentDocument?.body.querySelector(selector)) + + // if the video is absent, try again + if (!video) { + id = requestAnimationFrame(check) + return + } + + // programmatically focus the video element + video.focus() + + // in a dev/test environment only, append a signal to the outer document + document.body.dataset.videoState = 'loaded+focussed' + } + + id = requestAnimationFrame(check) + + return () => { + cancelAnimationFrame(id) + } + } +} diff --git a/packages/special-pages/pages/duckplayer/app/features/click-capture.js b/packages/special-pages/pages/duckplayer/app/features/click-capture.js new file mode 100644 index 000000000..758c897a5 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/features/click-capture.js @@ -0,0 +1,47 @@ +/** + * @typedef {import("./iframe").IframeFeature} IframeFeature + */ + +import { createYoutubeURLForError } from '../../src/js/utils.js' + +/** + * @implements IframeFeature + */ +export class ClickCapture { + /** + * @param {object} params + * @param {string} params.baseUrl + */ + constructor ({ baseUrl }) { + this.baseUrl = baseUrl + } + + /** + * @param {HTMLIFrameElement} iframe + */ + iframeDidLoad (iframe) { + const handler = (e) => { + if (!e.target) return + const target = /** @type {Element} */(e.target) + + // only act on elements with a `href` property + if (!('href' in target) || typeof target.href !== 'string') return + + // try to convert the clicked link into something we can open on Youtube + const next = createYoutubeURLForError(target.href, this.baseUrl) + if (!next) return + + e.preventDefault() + e.stopImmediatePropagation() + + // if we get this far, we want to prevent the new tab from opening and just redirect within the same tab + window.location.href = next + } + + iframe.contentDocument?.addEventListener('click', handler) + + return () => { + iframe.contentDocument?.removeEventListener('click', handler) + } + } +} diff --git a/packages/special-pages/pages/duckplayer/app/features/iframe.js b/packages/special-pages/pages/duckplayer/app/features/iframe.js new file mode 100644 index 000000000..bae6b85af --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/features/iframe.js @@ -0,0 +1,76 @@ +import { PIP } from './pip.js' +import { AutoFocus } from './autofocus.js' +import { ClickCapture } from './click-capture.js' +import { TitleCapture } from './title-capture.js' +import { MouseCapture } from './mouse-capture.js' + +/** + * Represents an individual piece of functionality in the iframe. + * + * @interface + */ +export class IframeFeature { + /** + * @param {HTMLIFrameElement} iframe + * @returns {(() => void) | null} + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + iframeDidLoad (iframe) { + return () => { + console.log('teardown') + } + } + + static noop () { + return { + iframeDidLoad: () => { return () => {} } + } + } +} + +/** + * Creates a known set of features for the iframe with access to + * global `Settings` + * + * @param {import("../settings").Settings} settings + * @returns {Record<string, () => IframeFeature>} + */ +export function createIframeFeatures (settings) { + return { + /** + * @return {IframeFeature} + */ + pip: () => { + if (settings.pip.state === 'enabled') { + return new PIP() + } + return IframeFeature.noop() + }, + /** + * @return {IframeFeature} + */ + autofocus: () => { + return new AutoFocus() + }, + /** + * @return {IframeFeature} + */ + clickCapture: () => { + return new ClickCapture({ + baseUrl: settings.youtubeBase + }) + }, + /** + * @return {IframeFeature} + */ + titleCapture: () => { + return new TitleCapture() + }, + /** + * @return {IframeFeature} + */ + mouseCapture: () => { + return new MouseCapture() + } + } +} diff --git a/packages/special-pages/pages/duckplayer/app/features/mouse-capture.js b/packages/special-pages/pages/duckplayer/app/features/mouse-capture.js new file mode 100644 index 000000000..9865f9df7 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/features/mouse-capture.js @@ -0,0 +1,22 @@ +/** + * @typedef {import("./iframe").IframeFeature} IframeFeature + */ + +/** + * Capture mousemove events inside the iframe and dispatch them + * This allows things like focus-mode to listen to this event and decide whether to + * pause or not. + * + * @implements IframeFeature + */ +export class MouseCapture { + /** + * @param {HTMLIFrameElement} iframe + */ + iframeDidLoad (iframe) { + iframe.contentDocument?.addEventListener('mousemove', () => { + window.dispatchEvent(new Event('iframe-mousemove')) + }) + return null + } +} diff --git a/packages/special-pages/pages/duckplayer/app/features/pip.js b/packages/special-pages/pages/duckplayer/app/features/pip.js new file mode 100644 index 000000000..e02f986f2 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/features/pip.js @@ -0,0 +1,30 @@ +/** + * @typedef {import("./iframe").IframeFeature} IframeFeature + */ + +/** + * @implements IframeFeature + */ +export class PIP { + /** + * @param {HTMLIFrameElement} iframe + */ + iframeDidLoad (iframe) { + try { + const iframeDocument = iframe.contentDocument + const iframeWindow = iframe.contentWindow + if (iframeDocument && iframeWindow) { + const CSSStyleSheet = /** @type {any} */(iframeWindow).CSSStyleSheet + const styleSheet = new CSSStyleSheet() + styleSheet.replaceSync('button.ytp-pip-button { display: inline-block !important; }') + // See https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets#append_a_new_stylesheet + iframeDocument.adoptedStyleSheets = [...iframeDocument.adoptedStyleSheets, styleSheet] + } + } catch (e) { + // ignore errors + console.warn(e) + } + + return null + } +} diff --git a/packages/special-pages/pages/duckplayer/app/features/title-capture.js b/packages/special-pages/pages/duckplayer/app/features/title-capture.js new file mode 100644 index 000000000..1e6a0fded --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/features/title-capture.js @@ -0,0 +1,54 @@ +/** + * @typedef {import("./iframe").IframeFeature} IframeFeature + */ +import { getValidVideoTitle } from '../../src/js/utils.js' + +/** + * @implements IframeFeature + */ +export class TitleCapture { + /** + * @param {HTMLIFrameElement} iframe + */ + iframeDidLoad (iframe) { + /** @type {(title: string) => void} */ + const setter = (title) => { + const validTitle = getValidVideoTitle(title) + if (validTitle) { + document.title = 'Duck Player - ' + validTitle + } + } + + const doc = iframe.contentDocument + const win = iframe.contentWindow + + if (!doc) { + console.log('could not access contentDocument') + return () => {} + } + + if (doc.title) { + // eslint-disable-next-line n/no-callback-literal + setter(doc.title) + } + if (win && doc) { + const titleElem = doc.querySelector('title') + + if (titleElem) { + // @ts-expect-error - typescript known about MutationObserver in this context + const observer = new win.MutationObserver(function (mutations) { + mutations.forEach(function (mutation) { + setter(mutation.target.textContent) + }) + }) + observer.observe(titleElem, { childList: true }) + } else { + console.warn('could not access title in iframe') + } + } else { + console.warn('could not access iframe?.contentWindow && iframe?.contentDocument') + } + + return null + } +} diff --git a/packages/special-pages/pages/duckplayer/app/img/cog.data.svg b/packages/special-pages/pages/duckplayer/app/img/cog.data.svg new file mode 100644 index 000000000..733481713 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/img/cog.data.svg @@ -0,0 +1,4 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M10 5.625C12.4162 5.625 14.375 7.58375 14.375 10C14.375 12.4162 12.4162 14.375 10 14.375C7.58375 14.375 5.625 12.4162 5.625 10C5.625 7.58375 7.58375 5.625 10 5.625ZM12.5 10C12.5 8.61929 11.3807 7.5 10 7.5C8.61929 7.5 7.5 8.61929 7.5 10C7.5 11.3807 8.61929 12.5 10 12.5C11.3807 12.5 12.5 11.3807 12.5 10Z" fill="white"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M10.625 0C11.5695 0 12.3509 0.698404 12.481 1.60697C13.0763 1.7827 13.6451 2.01996 14.1799 2.31125C14.9143 1.76035 15.9611 1.81892 16.6291 2.48696L17.513 3.37084C18.181 4.03887 18.2396 5.08556 17.6887 5.82C17.98 6.35484 18.2173 6.92365 18.393 7.51901C19.3016 7.64906 20 8.43047 20 9.375V10.625C20 11.5695 19.3016 12.3509 18.393 12.481C18.2173 13.0763 17.98 13.6451 17.6887 14.1799C18.2396 14.9143 18.1811 15.9611 17.513 16.6291L16.6292 17.513C15.9611 18.181 14.9144 18.2396 14.18 17.6887C13.6451 17.98 13.0763 18.2173 12.481 18.393C12.3509 19.3016 11.5695 20 10.625 20H9.375C8.43047 20 7.64906 19.3016 7.51901 18.393C6.92365 18.2173 6.35484 17.98 5.82 17.6887C5.08557 18.2396 4.03887 18.181 3.37084 17.513L2.48695 16.6291C1.81892 15.9611 1.76035 14.9143 2.31125 14.1799C2.01996 13.6451 1.7827 13.0763 1.60697 12.481C0.698404 12.3509 0 11.5695 0 10.625V9.375C0 8.43047 0.698404 7.64906 1.60697 7.51901C1.78271 6.92365 2.01998 6.35484 2.3113 5.82C1.76042 5.08557 1.81899 4.03887 2.48702 3.37084L3.37091 2.48695C4.03894 1.81892 5.08566 1.76035 5.82009 2.31125C6.35491 2.01996 6.92368 1.7827 7.51901 1.60697C7.64906 0.698403 8.43047 0 9.375 0H10.625ZM10.625 1.875H9.375C9.375 2.60562 8.86376 3.1857 8.2228 3.35667C7.63403 3.51372 7.07618 3.7471 6.5604 4.04579C5.98574 4.37857 5.21357 4.32962 4.69673 3.81278L3.81285 4.69666C4.32968 5.21349 4.37863 5.98566 4.04584 6.56031C3.74713 7.07612 3.51373 7.634 3.35667 8.2228C3.1857 8.86377 2.60562 9.375 1.875 9.375V10.625C2.60562 10.625 3.1857 11.1362 3.35667 11.7772C3.51372 12.366 3.7471 12.9238 4.04579 13.4396C4.37857 14.0143 4.32962 14.7864 3.81278 15.3033L4.69666 16.1872C5.21349 15.6703 5.98566 15.6214 6.56031 15.9542C7.07612 16.2529 7.634 16.4863 8.2228 16.6433C8.86376 16.8143 9.375 17.3944 9.375 18.125H10.625V17.6562C10.625 17.2103 10.939 16.8262 11.376 16.7375C12.1135 16.5878 12.8082 16.3199 13.4397 15.9542C14.0143 15.6214 14.7865 15.6703 15.3033 16.1871L16.1872 15.3033C15.6704 14.7864 15.6214 14.0143 15.9542 13.4396C16.2529 12.9238 16.4863 12.366 16.6433 11.7772C16.8143 11.1362 17.3944 10.625 18.125 10.625V9.375C17.3944 9.375 16.8143 8.86376 16.6433 8.2228C16.4863 7.63399 16.2529 7.07611 15.9542 6.5603C15.6214 5.98566 15.6703 5.2135 16.1871 4.69667L15.3033 3.81278C14.7864 4.32962 14.0143 4.37857 13.4396 4.04579C12.9238 3.7471 12.366 3.51372 11.7772 3.35667C11.1362 3.1857 10.625 2.60562 10.625 1.875Z" fill="white"/> +</svg> diff --git a/packages/special-pages/pages/duckplayer/src/assets/img/dax.svg b/packages/special-pages/pages/duckplayer/app/img/dax.data.svg similarity index 100% rename from packages/special-pages/pages/duckplayer/src/assets/img/dax.svg rename to packages/special-pages/pages/duckplayer/app/img/dax.data.svg diff --git a/packages/special-pages/pages/duckplayer/app/img/info.data.svg b/packages/special-pages/pages/duckplayer/app/img/info.data.svg new file mode 100644 index 000000000..4aa4b22a2 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/img/info.data.svg @@ -0,0 +1,5 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M10.604 4.55627C9.67439 4.55627 9.10934 5.3036 9.10934 5.99623C9.10934 6.83469 9.7473 7.1081 10.3123 7.1081C11.3513 7.1081 11.7888 6.32432 11.7888 5.68637C11.7888 4.88437 11.1508 4.55627 10.604 4.55627Z" fill="white"/> + <path d="M11.1413 8.18192L8.85806 8.55106C8.78817 9.10365 8.68764 9.66256 8.58552 10.2303C8.38843 11.326 8.18542 12.4546 8.18542 13.6339C8.18542 14.8049 8.88572 15.444 9.99318 15.444C11.258 15.444 11.4745 14.6501 11.5235 13.9309C10.475 14.083 10.2447 13.6097 10.416 12.4959C10.5874 11.382 11.1413 8.18192 11.1413 8.18192Z" fill="white"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M10 0C4.47715 0 0 4.47715 0 10C0 15.5228 4.47715 20 10 20C15.5228 20 20 15.5228 20 10C20 4.47715 15.5228 0 10 0ZM1.875 10C1.875 5.51269 5.51269 1.875 10 1.875C14.4873 1.875 18.125 5.51269 18.125 10C18.125 14.4873 14.4873 18.125 10 18.125C5.51269 18.125 1.875 14.4873 1.875 10Z" fill="white"/> +</svg> diff --git a/packages/special-pages/pages/duckplayer/app/img/mobile-bg.jpg b/packages/special-pages/pages/duckplayer/app/img/mobile-bg.jpg new file mode 100644 index 000000000..aefa07635 Binary files /dev/null and b/packages/special-pages/pages/duckplayer/app/img/mobile-bg.jpg differ diff --git a/packages/special-pages/pages/duckplayer/app/img/player-bg.jpg b/packages/special-pages/pages/duckplayer/app/img/player-bg.jpg new file mode 100644 index 000000000..bfb3a127e Binary files /dev/null and b/packages/special-pages/pages/duckplayer/app/img/player-bg.jpg differ diff --git a/packages/special-pages/pages/duckplayer/app/index.css b/packages/special-pages/pages/duckplayer/app/index.css new file mode 100644 index 000000000..8ad770c2e --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/index.css @@ -0,0 +1,9 @@ +@import url("base.css"); + +body[data-display="app"] { + color: rgba(242, 242, 242, 1); + background: #101010; + height: 100vh; + overflow: hidden; + padding: 8px; +} diff --git a/packages/special-pages/pages/duckplayer/app/index.js b/packages/special-pages/pages/duckplayer/app/index.js new file mode 100644 index 000000000..bc0d10034 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/index.js @@ -0,0 +1,128 @@ +import './index.css' +import { callWithRetry } from '../../../shared/call-with-retry.js' +import { h, render } from 'preact' +import { EnvironmentProvider, UpdateEnvironment, WillThrow } from '../../../shared/components/EnvironmentProvider.js' +import { TranslationProvider } from '../../../shared/components/TranslationsProvider.js' +import { ErrorBoundary } from '../../../shared/components/ErrorBoundary.js' +import { EmbedSettings } from './embed-settings.js' +import enStrings from '../src/locales/en/duckplayer.json' +import { Settings } from './settings.js' +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' + +/** + * @param {import("../src/js/index.js").DuckplayerPage} messaging + * @param {import("../../../shared/environment").Environment} baseEnvironment + * @return {Promise<void>} + */ +export async function init (messaging, baseEnvironment) { + const result = await callWithRetry(() => messaging.initialSetup()) + if ('error' in result) { + throw new Error(result.error) + } + + const init = result.value + console.log('INITIAL DATA', init) + + // update the 'env' in case it was changed by native sides + const environment = baseEnvironment + .withEnv(init.env) + .withLocale(init.locale) + .withLocale(baseEnvironment.urlParams.get('locale')) + .withTextLength(baseEnvironment.urlParams.get('textLength')) + .withDisplay(baseEnvironment.urlParams.get('display')) + + console.log('environment:', environment) + + document.body.dataset.display = environment.display + + 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 settings = new Settings({}) + .withPlatformName(baseEnvironment.injectName) + .withPlatformName(init.platform?.name) + .withPlatformName(baseEnvironment.urlParams.get('platform')) + .withFeatureState('pip', init.settings.pip) + .withFeatureState('autoplay', init.settings.autoplay) + .withDisabledFocusMode(baseEnvironment.urlParams.get('focusMode') === 'disabled') + + console.log(settings) + + const embed = createEmbedSettings(window.location.href, settings) + + const didCatch = (error) => { + const message = error?.message || 'unknown' + messaging.reportPageException({ message }) + } + + document.body.dataset.layout = settings.layout + + const root = document.querySelector('body') + if (!root) throw new Error('could not render, root element missing') + + if (environment.display === 'app') { + render( + <EnvironmentProvider + debugState={environment.debugState} + injectName={environment.injectName} + willThrow={environment.willThrow}> + <ErrorBoundary didCatch={didCatch} fallback={<Fallback showDetails={environment.env === 'development'}/>}> + <UpdateEnvironment search={window.location.search}/> + <TranslationProvider translationObject={strings} fallback={enStrings} textLength={environment.textLength}> + <MessagingContext.Provider value={messaging}> + <SettingsProvider settings={settings}> + <UserValuesProvider initial={init.userValues}> + <OrientationProvider> + <App embed={embed} /> + </OrientationProvider> + <WillThrow /> + </UserValuesProvider> + </SettingsProvider> + </MessagingContext.Provider> + </TranslationProvider> + </ErrorBoundary> + </EnvironmentProvider> + , root) + } else if (environment.display === 'components') { + render( + <EnvironmentProvider debugState={false} injectName={environment.injectName}> + <MessagingContext.Provider value={messaging}> + <TranslationProvider translationObject={strings} fallback={enStrings} textLength={environment.textLength}> + <Components /> + </TranslationProvider> + </MessagingContext.Provider> + </EnvironmentProvider> + , root) + } +} + +/** + * @param {string} href + * @param {import("./settings.js").Settings} settings + * @return {EmbedSettings|null} + */ +function createEmbedSettings (href, settings) { + const embed = EmbedSettings.fromHref(href) + if (!embed) return null + + return embed + .withAutoplay(settings.autoplay.state === 'enabled') + .withMuted(settings.platform.name === 'ios') +} diff --git a/packages/special-pages/pages/duckplayer/app/providers/OrientationProvider.jsx b/packages/special-pages/pages/duckplayer/app/providers/OrientationProvider.jsx new file mode 100644 index 000000000..038c47a79 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/providers/OrientationProvider.jsx @@ -0,0 +1,36 @@ +import { h } from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { createContext } from "preact"; + +const OrientationContext = createContext(/** @type {"landscape" | "portrait"} */("portrait")) + +/** + * 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. + */ +export function OrientationProvider ({ children }) { + const [orientation, setTheme] = useState(() => { + const initial = window.innerWidth > window.innerHeight ? 'landscape' : 'portrait' + return /** @type {"landscape"|"portrait"} */(initial) + }) + + useEffect(() => { + const listener = (e) => setTheme(window.innerWidth > window.innerHeight ? 'landscape' : 'portrait') + window.addEventListener('resize', listener) + return () => window.removeEventListener('resize', listener) + }, []) + + useEffect(() => { + document.body.dataset.orientation = orientation + }, [orientation]) + + return <OrientationContext.Provider value={orientation}> + {children} + </OrientationContext.Provider> +} + +export function useOrientation() { + return useContext(OrientationContext) +} diff --git a/packages/special-pages/pages/duckplayer/app/providers/SettingsProvider.jsx b/packages/special-pages/pages/duckplayer/app/providers/SettingsProvider.jsx new file mode 100644 index 000000000..ffab68312 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/providers/SettingsProvider.jsx @@ -0,0 +1,96 @@ +import { h } from "preact" +import { createContext } from "preact"; +import { Settings } from "../settings"; +import { useContext } from "preact/hooks"; +import { useMessaging } from "../types.js"; +import { EmbedSettings } from "../embed-settings"; + +const SettingsContext = createContext(/** @type {{settings: Settings}} */({})) + +/** + * @param {object} params + * @param {Settings} params.settings + * @param {import("preact").ComponentChild} params.children + */ +export function SettingsProvider({ settings, children }) { + return ( + <SettingsContext.Provider value={{settings}}> + {children} + </SettingsContext.Provider> + ) +} + +export function usePlatformName() { + return useContext(SettingsContext).settings.platform.name +} + +export function useLayout() { + return useContext(SettingsContext).settings.layout +} + +/** + * Handler for opening settings + */ +export function useOpenSettingsHandler() { + const settings = useContext(SettingsContext).settings; + const messaging = useMessaging(); + return () => { + switch (settings.platform.name) { + case "ios": + case "android": { + messaging.openSettings() + break + } + default: { + console.warn("unreachable!") + } + } + } +} + +export function useSettingsUrl() { + return 'duck://settings/duckplayer' +} + +export function useSettings() { + return useContext(SettingsContext).settings; +} + +/** + * Handler for opening info + */ +export function useOpenInfoHandler() { + const settings = useContext(SettingsContext).settings; + const messaging = useMessaging(); + return () => { + switch (settings.platform.name) { + case "android": + case "ios": { + messaging.openInfo() + break; + } + default: { + console.warn("unreachable!") + } + } + } +} + +/** + * Handler for opening info + */ +export function useOpenOnYoutubeHandler() { + const settings = useContext(SettingsContext).settings; + /** + * @param {EmbedSettings} embed + */ + return (embed) => { + if (!embed) return console.warn("unreachable, settings.embed must be present") + try { + const base = new URL(settings.youtubeBase); + window.location.href = embed.intoYoutubeUrl(base); + } catch (e) { + console.error("could not form a URL to open in Youtube", e) + } + } +} diff --git a/packages/special-pages/pages/duckplayer/app/providers/SwitchProvider.jsx b/packages/special-pages/pages/duckplayer/app/providers/SwitchProvider.jsx new file mode 100644 index 000000000..6f1e9d4c9 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/providers/SwitchProvider.jsx @@ -0,0 +1,81 @@ +import { createContext, h } from "preact"; +import { useEffect, useReducer } from "preact/hooks"; +import { useEnv } from "../../../../shared/components/EnvironmentProvider.js"; +import { useSetEnabled, useUserValues } from "./UserValuesProvider.jsx"; + + +/** + * @typedef {'showing' | 'exiting' | 'completed'} SwitchState + * @typedef {'change' | 'done' | 'enabled' | 'ask'} SwitchEvent + */ + +export const SwitchContext = createContext({ + /** @type {SwitchState} */ + state: 'showing', + /** @type {() => void} */ + onChange: () => { + throw new Error('must implement') + }, + /** @type {() => void} */ + onDone: () => { + throw new Error('must implement') + }, +}) + +export function SwitchProvider({ children }) { + const { isReducedMotion } = useEnv(); + const userValues = useUserValues(); + const setEnabled = useSetEnabled(); + const initialState = 'enabled' in userValues.privatePlayerMode ? 'completed' : 'showing' + + const [state, dispatch] = useReducer((/** @type {SwitchState} */state, /** @type {SwitchEvent} */event) => { + console.log("📩", {state,event}) + switch (state) { + case "showing": { + if (event === 'change') { + return 'exiting' + } + if (event === 'enabled') { + return 'completed' + } + if (event === 'done') { + return 'completed' + } + break; + } + case "exiting": { + if (event === 'done') { + return 'completed' + } + break; + } + case "completed": { + if (event === 'ask') { + return 'showing' + } + } + } + return state + }, initialState); + + function onChange() { + dispatch('change') + setEnabled() + } + + // sync the userValues with the state of the switch + useEffect(() => { + const evt = 'enabled' in userValues.privatePlayerMode ? 'enabled' : 'ask' + dispatch(evt); + }, [initialState]) + + function onDone() { + dispatch('done') + } + + return ( + <SwitchContext.Provider value={{state, onChange, onDone}}> + {children} + </SwitchContext.Provider> + ) +} diff --git a/packages/special-pages/pages/duckplayer/app/providers/UserValuesProvider.jsx b/packages/special-pages/pages/duckplayer/app/providers/UserValuesProvider.jsx new file mode 100644 index 000000000..72596759d --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/providers/UserValuesProvider.jsx @@ -0,0 +1,75 @@ +import { useContext, useState } from "preact/hooks"; +import { h, createContext } from "preact"; +import { useMessaging } from "../types.js"; +import { useEffect } from "preact/hooks"; + +/** + * @typedef {import("../../../../types/duckplayer").UserValues} UserValues + */ + +const UserValuesContext = createContext({ + /** @type {UserValues} */ + value: { + privatePlayerMode: { alwaysAsk: {} }, + overlayInteracted: false + }, + /** + * @type {() => void} + */ + setEnabled: () => { + // throw new Error('must implement') + } +}); + +/** + * @param {object} props + * @param {UserValues} props.initial + * @param {import("preact").ComponentChild} props.children + */ +export function UserValuesProvider({ initial, children }) { + // initial state + const [value, setValue] = useState(initial); + const messaging = useMessaging(); + + // listen for updates + useEffect(() => { + window.addEventListener('toggle-user-values-enabled', () => { + setValue({ privatePlayerMode: { enabled: {} }, overlayInteracted: false }) + }) + window.addEventListener('toggle-user-values-ask', () => { + setValue({ privatePlayerMode: { alwaysAsk: {} }, overlayInteracted: false }) + }) + const unsubscribe = messaging.onUserValuesChanged((userValues) => { + setValue(userValues) + }) + return () => unsubscribe(); + }, [messaging]) + + // API for consumers + function setEnabled() { + const values = { + privatePlayerMode: { enabled: {} }, + overlayInteracted: false + } + messaging.setUserValues(values) + .then((next) => { + console.log('response after setUserValues...', next) + console.log('will set', values) + setValue(values); + }) + .catch(err => { + console.error("could not set the enabled flag", err) + messaging.reportPageException({message: "could not set the enabled flag: " + err.toString()}) + }) + } + + return <UserValuesContext.Provider value={{ value, setEnabled }}>{children}</UserValuesContext.Provider> +} + +export function useUserValues() { + return useContext(UserValuesContext).value +} + +export function useSetEnabled() { + return useContext(UserValuesContext).setEnabled +} diff --git a/packages/special-pages/pages/duckplayer/app/settings.js b/packages/special-pages/pages/duckplayer/app/settings.js new file mode 100644 index 000000000..567a8fa25 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/settings.js @@ -0,0 +1,119 @@ +export class Settings { + /** + * @param {object} params + * @param {{name: ImportMeta['platform']}} [params.platform] + * @param {{state: 'enabled' | 'disabled'}} [params.pip] + * @param {{state: 'enabled' | 'disabled'}} [params.autoplay] + * @param {{state: 'enabled' | 'disabled'}} [params.focusMode] + */ + constructor ({ + platform = { name: 'macos' }, + pip = { state: 'disabled' }, + autoplay = { state: 'enabled' }, + focusMode = { state: 'enabled' } + }) { + this.platform = platform + this.pip = pip + this.autoplay = autoplay + this.focusMode = focusMode + } + + /** + * @param {keyof import("../../../types/duckplayer").DuckPlayerPageSettings} named + * @param {{state: 'enabled' | 'disabled'} | null | undefined} settings + * @return {Settings} + */ + withFeatureState (named, settings) { + if (!settings) return this + /** @type {(keyof import("../../../types/duckplayer").DuckPlayerPageSettings)[]} */ + const valid = ['pip', 'autoplay'] + if (!valid.includes(named)) return this + + if (settings.state === 'enabled' || settings.state === 'disabled') { + return new Settings({ + ...this, + [named]: settings + }) + } + return this + } + + withPlatformName (name) { + /** @type {ImportMeta['platform'][]} */ + const valid = ['windows', 'macos', 'ios', 'android'] + if (valid.includes(/** @type {any} */(name))) { + return new Settings({ + ...this, + platform: { name } + }) + } + return this + } + + /** + * @param {boolean} condition + * @return {Settings} + */ + withDisabledFocusMode (condition) { + /** @type {ImportMeta['platform'][]} */ + return new Settings({ + ...this, + focusMode: { + state: condition + ? 'disabled' + : 'enabled' + } + }) + } + + /** + * @return {string} + */ + get youtubeBase () { + switch (this.platform.name) { + case 'windows': + case 'ios': + case 'android': { + return 'duck://player/openInYoutube' + } + case 'macos': { + return 'https://www.youtube.com/watch' + } + default: throw new Error('unreachable') + } + } + + /** + * @return {'desktop' | 'mobile'} + */ + get layout () { + switch (this.platform.name) { + case 'windows': + case 'macos': { + return 'desktop' + } + case 'ios': + case 'android': { + return 'mobile' + } + 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/app/types.js b/packages/special-pages/pages/duckplayer/app/types.js new file mode 100644 index 000000000..9a71a0243 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/app/types.js @@ -0,0 +1,18 @@ +import { useContext } from 'preact/hooks' +import { TranslationContext } from '../../../shared/components/TranslationsProvider.js' +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import json from '../src/locales/en/duckplayer.json' +import { createContext } from 'preact' + +/** + * This is a wrapper to only allow keys from the default translation file + * @type {() => { t: (key: keyof json, replacements?: Record<string, string>) => string }} + */ +export function useTypedTranslation () { + return { + t: useContext(TranslationContext).t + } +} + +export const MessagingContext = createContext(/** @type {import("../src/js/index.js").DuckplayerPage} */({})) +export const useMessaging = () => useContext(MessagingContext) diff --git a/packages/special-pages/pages/duckplayer/src/assets/img/cog.svg b/packages/special-pages/pages/duckplayer/src/assets/img/cog.svg deleted file mode 100644 index c62b1b8e1..000000000 --- a/packages/special-pages/pages/duckplayer/src/assets/img/cog.svg +++ /dev/null @@ -1,3 +0,0 @@ -<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path fill-rule="evenodd" clip-rule="evenodd" d="M4.04862 13.1462C3.67876 14.0391 4.10278 15.0628 4.99571 15.4327L5.91959 15.8154C6.81252 16.1853 7.83621 15.7612 8.20608 14.8683L8.46289 14.2483C8.51353 14.2495 8.5643 14.2501 8.61518 14.2501C8.66579 14.2501 8.71629 14.2495 8.76666 14.2483L9.02343 14.8682C9.39329 15.7611 10.417 16.1852 11.3099 15.8153L12.2338 15.4326C13.1267 15.0628 13.5507 14.0391 13.1809 13.1461L12.9244 12.527C12.998 12.457 13.0698 12.3853 13.1398 12.3118L13.7588 12.5682C14.6517 12.9381 15.6754 12.514 16.0453 11.6211L16.428 10.6972C16.7978 9.8043 16.3738 8.7806 15.4809 8.41074L14.8633 8.15493C14.8645 8.10345 14.8652 8.05185 14.8652 8.00011C14.8652 7.94964 14.8646 7.89928 14.8634 7.84905L15.481 7.59324C16.3739 7.22337 16.7979 6.19968 16.4281 5.30675L16.0454 4.38287C15.6755 3.48994 14.6518 3.06592 13.7589 3.43578L13.1424 3.69115C13.0719 3.61712 12.9996 3.54482 12.9256 3.47432L13.181 2.85787C13.5508 1.96494 13.1268 0.94124 12.2339 0.571378L11.31 0.188694C10.4171 -0.181168 9.39337 0.242858 9.0235 1.13579L8.76828 1.75196C8.71737 1.75073 8.66634 1.75011 8.61518 1.75011C8.56375 1.75011 8.51245 1.75074 8.46127 1.75198L8.206 1.13569C7.83614 0.242766 6.81244 -0.181263 5.91951 0.1886L4.99564 0.571284C4.10271 0.941146 3.67868 1.96484 4.04854 2.85777L4.30416 3.47488C4.23024 3.54532 4.15805 3.61755 4.08765 3.69151L3.47044 3.43585C2.57751 3.06599 1.55382 3.49002 1.18395 4.38295L0.801271 5.30683C0.431408 6.19975 0.855436 7.22345 1.74836 7.59331L2.36697 7.84955C2.36578 7.89961 2.36518 7.9498 2.36518 8.00011C2.36518 8.05168 2.36581 8.10312 2.36706 8.15443L1.74846 8.41066C0.855529 8.78052 0.431502 9.80422 0.801365 10.6971L1.18405 11.621C1.55391 12.514 2.57761 12.938 3.47053 12.5681L4.09023 12.3114C4.16019 12.3848 4.23191 12.4565 4.30533 12.5265L4.04862 13.1462ZM2.22672 6.43846C1.9716 6.33279 1.85045 6.0403 1.95612 5.78518L2.3388 4.8613C2.44448 4.60618 2.73696 4.48503 2.99209 4.5907L4.09321 5.0468C4.31263 5.13769 4.56367 5.05988 4.71224 4.87459C4.94153 4.58865 5.20158 4.32845 5.48738 4.099C5.6725 3.95039 5.75018 3.69948 5.65934 3.48016L5.20339 2.37941C5.09772 2.12429 5.21887 1.83181 5.47399 1.72613L6.39787 1.34345C6.65299 1.23777 6.94547 1.35893 7.05115 1.61405L7.5067 2.71385C7.59757 2.93322 7.83 3.05571 8.06603 3.02993C8.24637 3.01022 8.4296 3.00011 8.61518 3.00011C8.80052 3.00011 8.98351 3.0102 9.16362 3.02985C9.39963 3.0556 9.63202 2.93311 9.72287 2.71377L10.1784 1.61414C10.284 1.35902 10.5765 1.23787 10.8316 1.34354L11.7555 1.72623C12.0106 1.8319 12.1318 2.12439 12.0261 2.37951L11.5704 3.47967C11.4796 3.69901 11.5573 3.94995 11.7424 4.09856C12.0283 4.32802 12.2885 4.58826 12.5178 4.87425C12.6664 5.05952 12.9174 5.13731 13.1369 5.04642L14.2372 4.59063C14.4924 4.48495 14.7849 4.6061 14.8905 4.86122L15.2732 5.7851C15.3789 6.04023 15.2577 6.33271 15.0026 6.43839L13.9017 6.89438C13.6825 6.98521 13.56 7.21748 13.5856 7.45343C13.6052 7.63298 13.6152 7.81537 13.6152 8.00011C13.6152 8.18597 13.605 8.36945 13.5853 8.55005C13.5595 8.78611 13.6819 9.01859 13.9013 9.10947L15.0025 9.56559C15.2576 9.67126 15.3788 9.96375 15.2731 10.2189L14.8904 11.1427C14.7848 11.3979 14.4923 11.519 14.2372 11.4133L13.1346 10.9566C12.9153 10.8658 12.6644 10.9435 12.5158 11.1285C12.2866 11.4139 12.0268 11.6736 11.7413 11.9026C11.5561 12.0511 11.4783 12.3021 11.5692 12.5215L12.026 13.6245C12.1317 13.8796 12.0106 14.1721 11.7554 14.2778L10.8316 14.6604C10.5764 14.7661 10.2839 14.645 10.1783 14.3898L9.72131 13.2866C9.63048 13.0673 9.39818 12.9449 9.16222 12.9705C8.98256 12.9901 8.80004 13.0001 8.61518 13.0001C8.43008 13.0001 8.24732 12.9901 8.06744 12.9705C7.83145 12.9447 7.59911 13.0672 7.50827 13.2865L7.05123 14.3899C6.94555 14.6451 6.65307 14.7662 6.39794 14.6605L5.47407 14.2779C5.21894 14.1722 5.09779 13.8797 5.20347 13.6246L5.66056 12.521C5.75143 12.3017 5.67369 12.0507 5.48849 11.9021C5.2031 11.6731 4.94337 11.4135 4.71429 11.1282C4.56568 10.9431 4.31478 10.8654 4.09548 10.9563L2.99218 11.4133C2.73706 11.5189 2.44457 11.3978 2.3389 11.1427L1.95621 10.2188C1.85054 9.96367 1.97169 9.67119 2.22681 9.56551L3.32897 9.10898C3.54835 9.01811 3.67084 8.78566 3.64503 8.54962C3.62531 8.36916 3.61518 8.18582 3.61518 8.00011C3.61518 7.81552 3.62518 7.63327 3.64468 7.45386C3.67031 7.21793 3.54782 6.98568 3.32857 6.89486L2.22672 6.43846ZM6.7402 8.00008C6.7402 6.96455 7.57966 6.12508 8.6152 6.12508C9.65073 6.12508 10.4902 6.96455 10.4902 8.00008C10.4902 9.03562 9.65073 9.87508 8.6152 9.87508C7.57966 9.87508 6.7402 9.03562 6.7402 8.00008ZM8.6152 4.87508C6.88931 4.87508 5.4902 6.27419 5.4902 8.00008C5.4902 9.72597 6.88931 11.1251 8.6152 11.1251C10.3411 11.1251 11.7402 9.72597 11.7402 8.00008C11.7402 6.27419 10.3411 4.87508 8.6152 4.87508Z" fill="white" fill-opacity="0.8"/> -</svg> diff --git a/packages/special-pages/pages/duckplayer/src/assets/img/eyeball.svg b/packages/special-pages/pages/duckplayer/src/assets/img/eyeball.svg deleted file mode 100644 index da9bd6ee7..000000000 --- a/packages/special-pages/pages/duckplayer/src/assets/img/eyeball.svg +++ /dev/null @@ -1,8 +0,0 @@ -<svg width="52" height="52" viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M9.94816 17.9071C5.48524 26.7822 9.05984 37.5983 17.9323 42.0655C26.8047 46.5327 37.6151 42.9594 42.078 34.0843C46.5409 25.2092 42.9663 14.3932 34.0939 9.92595C25.2215 5.45874 14.4111 9.03204 9.94816 17.9071Z" fill="#C7B9EE"/> - <path opacity="0.5" d="M33.6359 42.2915C29.327 43.1601 24.8489 42.4196 21.0477 40.21C17.2466 38.0003 14.3857 34.4745 13.0057 30.2989C11.6257 26.1232 11.8221 21.5868 13.5579 17.547C15.2936 13.5072 18.4484 10.2437 22.4262 8.3732C19.9329 8.87583 17.5745 9.90196 15.5071 11.3838C13.4396 12.8657 11.71 14.7696 10.4325 16.9698C9.15501 19.1699 8.35867 21.6163 8.09608 24.1474C7.83349 26.6785 8.11062 29.2366 8.90918 31.6529C9.70774 34.0692 11.0096 36.2887 12.7287 38.1647C14.4478 40.0408 16.545 41.5307 18.882 42.5362C21.2189 43.5416 23.7423 44.0397 26.2856 43.9975C28.8289 43.9554 31.3341 43.3739 33.6359 42.2915Z" fill="#A591DC"/> - <path d="M28.8678 19.6448C23.7034 20.8278 20.4737 25.9737 21.6541 31.1384C22.8345 36.3032 27.9779 39.531 33.1423 38.3479C38.3066 37.1649 41.5363 32.019 40.3559 26.8543C39.1756 21.6895 34.0321 18.4617 28.8678 19.6448Z" fill="#876ECB"/> - <path d="M31.1017 23.8775C28.1967 24.543 26.38 27.4375 27.044 30.3427C27.7079 33.2478 30.6011 35.0635 33.5061 34.398C36.411 33.7326 38.2277 30.838 37.5638 27.9328C36.8998 25.0277 34.0066 23.212 31.1017 23.8775Z" fill="#3E228C"/> - <path d="M33.369 19.5309C31.668 19.9205 30.6042 21.6155 30.993 23.3166C31.3818 25.0177 33.0759 26.0809 34.7769 25.6912C36.4779 25.3016 37.5417 23.6066 37.1529 21.9055C36.7641 20.2044 35.07 19.1412 33.369 19.5309Z" fill="#ECE6FF"/> - <path d="M42.5574 9.46473C38.322 5.22367 32.4674 2.59998 26.0001 2.59998C13.0766 2.59998 2.6001 13.0765 2.6001 26C2.6001 32.4673 5.22379 38.3219 9.46485 42.5573M42.5574 9.46473C46.7855 13.6984 49.4001 19.5439 49.4001 26C49.4001 38.9234 38.9236 49.4 26.0001 49.4C19.544 49.4 13.6985 46.7854 9.46485 42.5573M42.5574 9.46473L9.46485 42.5573" stroke="#EE1025" stroke-width="3.6"/> -</svg> diff --git a/packages/special-pages/pages/duckplayer/src/assets/img/info-icon.svg b/packages/special-pages/pages/duckplayer/src/assets/img/info-icon.svg deleted file mode 100644 index dee6a9b25..000000000 --- a/packages/special-pages/pages/duckplayer/src/assets/img/info-icon.svg +++ /dev/null @@ -1,12 +0,0 @@ -<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg" class="info-icon"> - <g clip-path="url(#clip0_1010_25499)"> - <path d="M9.09842 3.64453C8.35475 3.64453 7.90271 4.24239 7.90271 4.7965C7.90271 5.46726 8.41307 5.68599 8.86511 5.68599C9.69628 5.68599 10.0462 5.05897 10.0462 4.54861C10.0462 3.907 9.53588 3.64453 9.09842 3.64453Z" fill="white" fill-opacity="0.84"/> - <path d="M9.52827 6.54504L7.70168 6.84036C7.64577 7.28244 7.56534 7.72956 7.48365 8.18373C7.32597 9.06033 7.16357 9.96319 7.16357 10.9066C7.16357 11.8434 7.72381 12.3547 8.60978 12.3547C9.62163 12.3547 9.7948 11.7196 9.83405 11.1442C8.99523 11.2659 8.81096 10.8873 8.94806 9.99619C9.08515 9.10509 9.52827 6.54504 9.52827 6.54504Z" fill="white" fill-opacity="0.84"/> - <path fill-rule="evenodd" clip-rule="evenodd" d="M8.61523 0C4.19696 0 0.615234 3.58172 0.615234 8C0.615234 12.4183 4.19696 16 8.61523 16C13.0335 16 16.6152 12.4183 16.6152 8C16.6152 3.58172 13.0335 0 8.61523 0ZM1.86523 8C1.86523 4.27208 4.88731 1.25 8.61523 1.25C12.3432 1.25 15.3652 4.27208 15.3652 8C15.3652 11.7279 12.3432 14.75 8.61523 14.75C4.88731 14.75 1.86523 11.7279 1.86523 8Z" fill="white" fill-opacity="0.84"/> - </g> - <defs> - <clipPath id="clip0_1010_25499"> - <rect width="16" height="16" fill="white" transform="translate(0.615234)"/> - </clipPath> - </defs> -</svg> diff --git a/packages/special-pages/pages/duckplayer/src/assets/img/open.svg b/packages/special-pages/pages/duckplayer/src/assets/img/open.svg deleted file mode 100644 index d5943118d..000000000 --- a/packages/special-pages/pages/duckplayer/src/assets/img/open.svg +++ /dev/null @@ -1,4 +0,0 @@ -<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M4.86523 2.5C3.89874 2.5 3.11523 3.2835 3.11523 4.25V11.75C3.11523 12.7165 3.89874 13.5 4.86523 13.5H12.3652C13.3317 13.5 14.1152 12.7165 14.1152 11.75V8.75C14.1152 8.33579 14.451 8 14.8652 8C15.2794 8 15.6152 8.33579 15.6152 8.75V11.75C15.6152 13.5449 14.1602 15 12.3652 15H4.86523C3.07031 15 1.61523 13.5449 1.61523 11.75V4.25C1.61523 2.45507 3.07031 1 4.86523 1H7.86523C8.27945 1 8.61523 1.33579 8.61523 1.75C8.61523 2.16421 8.27945 2.5 7.86523 2.5H4.86523Z" fill="white" fill-opacity="0.84"/> - <path d="M10.6152 1.75C10.6152 1.33579 10.951 1 11.3652 1H14.8652C15.2794 1 15.6152 1.33579 15.6152 1.75V5.25C15.6152 5.66421 15.2794 6 14.8652 6C14.451 6 14.1152 5.66421 14.1152 5.25V3.56066L9.89556 7.78033C9.60267 8.07322 9.1278 8.07322 8.8349 7.78033C8.54201 7.48744 8.54201 7.01256 8.8349 6.71967L13.0546 2.5H11.3652C10.951 2.5 10.6152 2.16421 10.6152 1.75Z" fill="white" fill-opacity="0.84"/> -</svg> diff --git a/packages/special-pages/pages/duckplayer/src/assets/img/player-bg.png b/packages/special-pages/pages/duckplayer/src/assets/img/player-bg.png deleted file mode 100644 index 83a4b8c33..000000000 Binary files a/packages/special-pages/pages/duckplayer/src/assets/img/player-bg.png and /dev/null differ diff --git a/packages/special-pages/pages/duckplayer/src/assets/player.css b/packages/special-pages/pages/duckplayer/src/assets/player.css deleted file mode 100644 index b516c9556..000000000 --- a/packages/special-pages/pages/duckplayer/src/assets/player.css +++ /dev/null @@ -1,263 +0,0 @@ -:root { - --aspect-ratio: calc(9 / 16); - --toolbar-height: 56px; - - /* Set video to take up 80vw width */ - --video-width: 80vw; - - /* Calculate video height based on aspect ratio, but never exceed 80vh - * for the video height, for example when using short, wide screens. - */ - --video-height: min(calc(var(--video-width) * var(--aspect-ratio)), 80vh); -} - -@media screen and (max-width: 1080px) { - :root { - --video-width: 85vw; - } -} - -@media screen and (max-width: 740px) { - :root { - --video-width: 90vw; - } -} - -.bg { - background: url('img/player-bg.png'); -} - -.bg { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; - background-size: cover; - opacity: 0.6; -} - -.bg::after { - content: ''; - position: absolute; - left: 0; - right: 0; - top: -50%; - height: 100%; - background: linear-gradient(0deg, rgba(0, 0, 0, 0), #101010 70%); - transition: all 1s ease-out; -} - -body.faded .bg::after { - top: 30%; -} - -body { - color: rgba(255, 255, 255, 0.85); - font-family: system-ui; - font-size: 13px; - line-height: 16px; - letter-spacing: -0.08px; - margin: 0; - background: #101010; - - /* Make it feel more like something native */ - -webkit-user-select: none; - cursor: default; -} - -.player-container { - overflow: hidden; - z-index: 10; - position: relative; - background: black; -} - -.player-container, -#player { - width: var(--video-width); - height: var(--video-height); - max-width: 3840px; -} - -.player-error { - text-align: center; - line-height: var(--video-height); - background: #2f2f2f; -} - -.content-hover { - --content-padding: 1px; - padding: var(--content-padding); - width: var(--video-width); - - /* Set margin left to be half of the remaining vw - video width */ - margin-left: calc(((100vw - var(--video-width)) / 2) - var(--content-padding)); - - /* Set margin-top to be half of the remaaining vh - video and toolbar height, but never less than 0px. */ - margin-top: max(0px, calc((100vh - (var(--video-height) + var(--toolbar-height))) / 2)); - position: absolute; -} - -.toolbar { - background: rgba(0, 0, 0, 0.3); - border-radius: 0px 0px 12px 12px; - transition: all 0.5s linear; - opacity: 1; - margin-top: -12px; - padding: 12px; - padding-top: 24px; - height: 32px; - display: flex; -} - -@media (prefers-reduced-motion) { - .toolbar { - transition: none; - } -} - -body.faded .toolbar { - opacity: 0; - margin-top: -80px; -} - -.logo { - font-style: normal; - font-weight: 600; - color: #ffffff; - display: flex; - align-items: center; -} - -.dax-icon { - margin-right: 5px; -} - -.info-icon-container { - position: relative; - display: inline-block; - margin-left: 4px; -} - -.info-icon { - margin-bottom: -4px; -} - -.info-icon:hover path { - fill: rgba(255, 255, 255, 0.8); -} - -.info-icon-tooltip { - position: absolute; - background: linear-gradient(0deg, rgba(48, 48, 48, 0.35), rgba(48, 48, 48, 0.35)), rgba(33, 33, 33, 0.55); - background-blend-mode: normal, luminosity; - box-shadow: inset 0px 0px 1px #ffffff; - filter: drop-shadow(0px 0px 1px #000000) drop-shadow(0px 0px 1px #000000) drop-shadow(0px 0px 1px #000000) - drop-shadow(0px 8px 16px rgba(0, 0, 0, 0.2)); - backdrop-filter: blur(76px); - border-radius: 10px; - width: 300px; - font-weight: normal; - padding: 12px; - left: -162px; - top: 32px; - display: none; -} - -.info-icon-tooltip::after { - content: ''; - width: 15px; - height: 15px; - border: 1px solid #5f5f5f; - display: block; - position: absolute; - top: -8px; - border-right: none; - border-bottom: none; - transform: rotate(45deg); - background: #1d1d1d; - left: 162px; -} - -.info-icon-tooltip.above { - top: -105px; - z-index: 50; -} - -.info-icon-tooltip.above::after { - top: 80px; - transform: rotate(225deg); -} - -.info-icon-tooltip.visible { - display: block; -} - -.options { - margin-left: auto; - display: flex; - align-items: center; -} - -.setting-container { - overflow: hidden; - white-space: nowrap; - margin-right: 0; - width: 299px; -} - -.setting-container.animatable { - transition: 0.3s linear all; -} - -@media (prefers-reduced-motion) { - .setting-container.animatable { - transition: none - } -} - -.setting-container.invisible { - width: 0px; -} - -@media screen and (max-width: 760px) { - .setting-container { - width: calc(var(--video-width) - 370px); - margin-right: 8px; - text-overflow: ellipsis; - } -} - -.settings-label { - cursor: pointer; - display: flex; - align-items: center; -} -.settings-checkbox { - margin-right: 4px; - width: 14px; - height: 14px; -} - -.options-button { - height: 16px; - padding: 8px; - background: rgba(255, 255, 255, 0.18); - border-radius: 8px; - float: left; - color: white; - text-decoration: none; - margin-left: 8px; - font-weight: bold; - text-align: center; -} - -.options-button:hover, -.options-button.active { - background: rgba(255, 255, 255, 0.28); -} - -.play-on-youtube img { - margin-bottom: -3px; -} diff --git a/packages/special-pages/pages/duckplayer/src/index.html b/packages/special-pages/pages/duckplayer/src/index.html index 91959d2a0..f48bb6eef 100644 --- a/packages/special-pages/pages/duckplayer/src/index.html +++ b/packages/special-pages/pages/duckplayer/src/index.html @@ -2,50 +2,13 @@ <html lang="en"> <head> <title>Duck Player</title> + <meta name="robots" content="noindex,nofollow"> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <link rel="stylesheet" href="./assets/player.css" /> + <script src="js/inline.js"></script> + <link rel="stylesheet" href="./js/index.css" /> </head> <body> -<div class="bg"></div> -<div class="content-hover"> - <div class="player-container"> - <iframe - id="player" - frameborder="0" - allow="autoplay; encrypted-media; fullscreen" - allowfullscreen - sandbox="allow-popups allow-scripts allow-same-origin allow-popups-to-escape-sandbox" - ></iframe> - </div> - <div class="toolbar"> - <div class="logo"> - <img src="./assets/img/dax.svg" class="dax-icon" alt="" /> - Duck Player - <div class="info-icon-container"> - <img src="./assets/img/info-icon.svg" alt="" class="info-icon" /> - <div class="info-icon-tooltip"> - Duck Player provides a clean viewing experience without personalized ads and prevents viewing activity from - influencing your YouTube recommendations. - </div> - </div> - </div> - <div class="options"> - <form class="setting-container invisible"> - <label for="setting" class="settings-label"> - <input id="setting" type="checkbox" class="settings-checkbox" /> - <span>Always open YouTube videos in Duck Player</span> - </label> - </form> - <a href="#" aria-label="Open Settings" target="_blank" class="options-button open-settings" rel="noopener"> - <img src="./assets/img/cog.svg" alt="" /> - </a> - <a href="#" class="options-button play-on-youtube" rel="noopener"> - <img src="./assets/img/open.svg" alt="" /> - <span>Watch on YouTube</span> - </a> - </div> - </div> -</div> +<div id="app"></div> <script type="module" src="js/index.js"></script> </body> </html> diff --git a/packages/special-pages/pages/duckplayer/src/js/index.js b/packages/special-pages/pages/duckplayer/src/js/index.js index df806ccc6..368881e81 100644 --- a/packages/special-pages/pages/duckplayer/src/js/index.js +++ b/packages/special-pages/pages/duckplayer/src/js/index.js @@ -1,878 +1,130 @@ -/** - * @module Duck Player Page - * @category Special Pages - * - * @description - * - * DuckPlayer Page can be embedded into special contexts. It will currently look for a video ID in the - * following order of precedence. - * - * Assuming the video ID is `123`: - * - * - 1) `duck://player?videoID=123` - * - 2) `duck://player/123` - * - 3) `https://youtube-nocookie.com/embed/123` - * - * ### Integration - * - * #### Assets/HTML - * - * - macOS: use `pages/duckplayer/index.html`, everything is inlined into that single file - * - windows: load the folder of assets under `pages/duckplayer` - * - * #### Messages: - * - * On Page Load - * - {@link DuckPlayerPageMessages.getUserValues} is initially called to get the current settings - * - {@link DuckPlayerPageMessages.onUserValuesChanged} subscription begins immediately - it will continue to listen for updates - * - * Then the following message can be sent at any time - * - {@link DuckPlayerPageMessages.setUserValues} - * - * Please see {@link DuckPlayerPageMessages} for the up-to-date list - */ -import { - DuckPlayerPageMessages, - UserValues -} from './messages' -import { html } from '../../../../../../src/dom-utils' -import { initStorage } from './storage' -import { createYoutubeURLForError } from './utils' -import { createSpecialPageMessaging } from '../../../../shared/create-special-page-messaging' -import { callWithRetry } from '../../../../shared/call-with-retry' - -// for docs -export { DuckPlayerPageMessages, UserValues } - -const VideoPlayer = { - /** - * Returns the video player iframe - * @returns {HTMLIFrameElement} - */ - iframe: () => { - // @ts-expect-error - Type 'HTMLElement | null' is not assignable to type 'HTMLIFrameElement'. - return document.querySelector('#player') - }, - - /** - * Returns the iframe player container - * @returns {HTMLElement} - */ - playerContainer: () => { - // @ts-expect-error - Type 'HTMLElement | null' is not assignable to type 'HTMLElement'. - return document.querySelector('.player-container') - }, +import { createTypedMessages } from '@duckduckgo/messaging' +import { Environment } from '../../../../shared/environment.js' +import { createSpecialPageMessaging } from '../../../../shared/create-special-page-messaging.js' +import { init } from '../../app/index.js' +import { initStorage } from './storage.js' +export class DuckplayerPage { /** - * Returns the full YouTube embed URL to be used for the player iframe - * @param {string} videoId - * @param {number|boolean} timestamp - * @returns {string} + * @param {import("@duckduckgo/messaging").Messaging} messaging */ - videoEmbedURL: (videoId, timestamp) => { - const url = new URL(`/embed/${videoId}`, 'https://www.youtube-nocookie.com') - - url.searchParams.set('iv_load_policy', '1') // show video annotations - url.searchParams.set('autoplay', '1') // autoplays the video as soon as it loads - url.searchParams.set('rel', '0') // shows related videos from the same channel as the video - url.searchParams.set('modestbranding', '1') // disables showing the YouTube logo in the video control bar - - if (timestamp) { - url.searchParams.set('start', String(timestamp)) // if timestamp supplied, start video at specific point - } - - return url.href - }, - /** - * Sets up the video player: - * 1. Fetches the video id - * 2. If the video id is correctly formatted, it loads the YouTube video in the iframe, otherwise displays an error message - * @param {object} opts - * @param {string} opts.base - * @param {ImportMeta['env']} opts.env - * @param {import('./messages').DuckPlayerPageSettings} opts.settings - */ - init: (opts) => { - VideoPlayer.loadVideoById() - VideoPlayer.autoFocusVideo(opts.env) - VideoPlayer.setTabTitle() - VideoPlayer.setClickListener(opts.base) - if (opts.settings.pip.state === 'enabled') { - VideoPlayer.enablePiP() - } - }, + constructor (messaging, injectName) { + this.messaging = createTypedMessages(this, messaging) + this.injectName = injectName + } /** - * In certain circumstances, we may want to intercept - * clicks within the iframe - for example when showing a video - * that cannot be played in the embed - * - * @param {string} urlBase - macos/windows current use a different base URL - */ - setClickListener: (urlBase) => { - VideoPlayer.onIframeLoaded(() => { - const iframe = VideoPlayer.iframe() - iframe.contentDocument?.addEventListener('click', (e) => { - if (!e.target) return - const target = /** @type {Element} */(e.target) - - // only act on elements with a `href` property - if (!('href' in target) || typeof target.href !== 'string') return - - // try to convert the clicked link into something we can open on Youtube - const next = createYoutubeURLForError(target.href, urlBase) - if (!next) return - - // if we get this far, we want to prevent the new tab from opening and just redirect within the same tab - e.preventDefault() - e.stopImmediatePropagation() - window.location.href = next + * This will be sent if the application has loaded, but a client-side error + * has occurred that cannot be recovered from + * @returns {Promise<import("../../../../types/duckplayer").InitialSetupResponse>} + */ + initialSetup () { + if (this.injectName === 'integration') { + return Promise.resolve({ + platform: { name: 'ios' }, + env: 'development', + userValues: { privatePlayerMode: { alwaysAsk: {} }, overlayInteracted: false }, + settings: { + pip: { + state: 'enabled' + }, + autoplay: { + state: 'enabled' + } + }, + locale: 'en' }) - }) - }, - - /** - * Tries loading the video if there's a valid video id, otherwise shows error message. - */ - loadVideoById: () => { - const validVideoId = Comms.getValidVideoId() - const timestamp = Comms.getSanitizedTimestamp() - - if (validVideoId) { - VideoPlayer.iframe().setAttribute('src', VideoPlayer.videoEmbedURL(validVideoId, timestamp)) - } else { - VideoPlayer.showVideoError('Invalid video id') - } - }, - - /** - * Show an error instead of the video player iframe - */ - showVideoError: (errorMessage) => { - VideoPlayer.playerContainer().innerHTML = html`<div class="player-error"><b>ERROR:</b> <span class="player-error-message"></span></div>`.toString() - - // @ts-expect-error - Type 'HTMLElement | null' is not assignable to type 'HTMLElement'. - document.querySelector('.player-error-message').textContent = errorMessage - }, - - /** - * Trigger callback when the video player iframe has loaded - * @param {() => void} callback - */ - onIframeLoaded: (callback) => { - const iframe = VideoPlayer.iframe() - - if (VideoPlayer.loaded) { - callback() - } else { - if (iframe) { - iframe.addEventListener('load', () => { - VideoPlayer.loaded = true - callback() - }) - } - } - }, - - /** - * Fires whenever the video player iframe <title> changes (the video doesn't have the <title> set to - * the video title until after the video has loaded...) - * @param {(title: string) => void} callback - */ - onIframeTitleChange: (callback) => { - const iframe = VideoPlayer.iframe() - - if (iframe?.contentDocument?.title) { - // eslint-disable-next-line n/no-callback-literal - callback(iframe?.contentDocument?.title) - } - if (iframe?.contentWindow && iframe?.contentDocument) { - const titleElem = iframe.contentDocument.querySelector('title') - - if (titleElem) { - // @ts-expect-error - typescript known about MutationObserver in this context - const observer = new iframe.contentWindow.MutationObserver(function (mutations) { - mutations.forEach(function (mutation) { - callback(mutation.target.textContent) - }) - }) - observer.observe(titleElem, { childList: true }) - } else { - // console.warn('could not access title in iframe') - } - } else { - // console.warn('could not access iframe?.contentWindow && iframe?.contentDocument') - } - }, - - /** - * Get the video title from the video iframe. - * @returns {string|false} - */ - getValidVideoTitle: (iframeTitle) => { - if (iframeTitle) { - if (iframeTitle === 'YouTube') { - return false - } - - return iframeTitle.replace(/ - YouTube$/g, '') } - - return false - }, - - /** - * Sets the tab title to the title of the video once the video title has loaded. - */ - setTabTitle: () => { - VideoPlayer.onIframeLoaded(() => { - VideoPlayer.onIframeTitleChange((title) => { - const validTitle = VideoPlayer.getValidVideoTitle(title) - - if (validTitle) { - document.title = 'Duck Player - ' + validTitle - } - }) - }) - }, - - enablePiP: () => { - VideoPlayer.onIframeLoaded(() => { - try { - const iframe = VideoPlayer.iframe() - const iframeDocument = iframe?.contentDocument - const iframeWindow = iframe?.contentWindow - if (iframeDocument && iframeWindow) { - // @ts-expect-error - typescript doesn't know about CSSStyleSheet here for some reason - const styleSheet = new iframeWindow.CSSStyleSheet() - styleSheet.replaceSync('button.ytp-pip-button { display: inline-block !important; }') - // See https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets#append_a_new_stylesheet - iframeDocument.adoptedStyleSheets = [...iframeDocument.adoptedStyleSheets, styleSheet] - } - } catch (e) { - // ignore errors - console.warn(e) - } - }) - }, - - /** - * Wait for the video to load and then focus it - * @param {ImportMeta['env']} env - */ - autoFocusVideo: (env) => { - VideoPlayer.onIframeLoaded(() => { - const contentDocument = VideoPlayer.iframe().contentDocument - if (!contentDocument) return - - const maxAttempts = 1000 - let attempt = 0 - - function check () { - if (!contentDocument) return - if (attempt > maxAttempts) return - - attempt += 1 - - // try to select the video element - const video = /** @type {HTMLIFrameElement | null} */(contentDocument?.body.querySelector('#player video')) - - // if the video is absent, try again - if (!video) { - requestAnimationFrame(check) - return - } - - // programmatically focus the video element - video.focus() - - // in a dev/test environment only, append a signal to the outer document - if (env === 'development') { - document.body.dataset.videoState = 'loaded+focussed' - } - } - - requestAnimationFrame(check) - }) + return this.messaging.request('initialSetup') } -} - -const Comms = { - /** @type {DuckPlayerPageMessages | undefined} */ - messaging: undefined, - /** - * NATIVE NOTE: Gets the video id from the location object, works for MacOS < > 12 - * @return {string} - */ - getVideoIdFromLocation: () => { - /** - * In MacOS < 12, the video id is the entire 'pathname' (duck://player/123 <- /123 is the 'pathname') - * In MacOS 12+, the video id is what's after "/embed/" in 'pathname' because of the - * top level being youtube-nocookie.com/embed/123?... <- /embed/123 is the 'pathname' - */ - const url = new URL(window.location.href) - const params = Object.fromEntries(url.searchParams) - if (typeof params.videoID === 'string') { - return params.videoID - } - if (window.location.protocol === 'duck:') { - return window.location.pathname.substr(1) - } else { - return window.location.pathname.replace('/embed/', '') - } - }, - - /** - * Validates that the input string is a valid video id. - * If so, returns the video id otherwise returns false. - * @param {string} input - * @returns {(string|null)} - */ - sanitiseVideoId: (input) => { - if (typeof input !== 'string') return null - const subject = input.slice(0, 11) - if (/^[a-zA-Z0-9-_]+$/g.test(subject)) { - return subject - } - return null - }, - - /** - * Returns a sanitized video id if there is a valid one. - * @returns {(string|null)} - */ - getValidVideoId: () => { - return Comms.sanitiseVideoId(Comms.getVideoIdFromLocation()) - }, - - /** - * Gets the video id - * @returns {number|boolean} - */ - getSanitizedTimestamp: () => { - if (window.location && window.location.search) { - const parameters = new URLSearchParams(window.location.search) - const timeParameter = parameters.get('t') - - if (timeParameter) { - return Comms.getTimestampInSeconds(timeParameter) - } - - return false - } - return false - }, - - /** - * Sanitizes and converts timestamp to an integer of seconds, - * input may be in the format 1h30m20s (each unit optional) - * (iframe only takes seconds as parameter...) - * todo(Shane): unit tests for this! - * @param {string} timestamp - * @returns {(number|false)} - */ - getTimestampInSeconds: (timestamp) => { - const units = { - h: 3600, - m: 60, - s: 1 - } - - const parts = timestamp.split(/(\d+[hms]?)/) - - const totalSeconds = parts.reduce((total, part) => { - if (!part) return total - - for (const unit in units) { - if (part.includes(unit)) { - return total + (parseInt(part) * units[unit]) - } - } - - return total - }, 0) - - if (totalSeconds > 0) { - return totalSeconds - } - - return false - }, - - /** - * Based on e, returns whether the received message is valid. - * @param {any} e - * @returns {boolean} - */ - isValidMessage: (e, message) => { - if (import.meta.env === 'development') { - console.warn('Allowing all messages because we are in development mode') - return true - } - if (import.meta.injectName === 'windows') { - // todo(Shane): Verify this message - console.log('WINDOWS: allowing message', e) - return true - } - const hasMessage = e && e.data && typeof e.data[message] !== 'undefined' - const isValidMessage = hasMessage && (e.data[message] === true || e.data[message] === false) - - // todo(Shane): Verify this is ok on macOS - const hasCorrectOrigin = e.origin && (e.origin === 'https://www.youtube-nocookie.com' || e.origin === 'duck://player') - - if (isValidMessage && hasCorrectOrigin) { - return true - } - - return false - }, /** - * Starts listening for 'alwaysOpenSetting' coming from native, and if we receive it - * update the 'Setting' to the value of the message (true || false) + * This is sent when the user wants to set Duck Player as the default. * - * To mock, use: - * - * `window.postMessage({ alwaysOpenSetting: false })` - * - * @param {DuckPlayerPageMessages} messaging - * @return {Promise<{value: import('./messages').InitialSetup} | { error: string}>} - */ - init: async (messaging) => { - // try to make communication with the native side. - const result = await callWithRetry(() => { - return messaging.initialSetup() - }) - // if we received a connection, use the initial values - if ('value' in result) { - Comms.messaging = messaging - const { userValues } = result.value - if ('enabled' in userValues.privatePlayerMode) { - Setting.setState(true) - } else { - Setting.setState(false) - } - // eslint-disable-next-line promise/prefer-await-to-then - Comms.messaging?.onUserValuesChanged(value => { - if ('enabled' in value.privatePlayerMode) { - Setting.setState(true) - } else { - Setting.setState(false) - } - }) - } - return result - }, - /** - * From the player page, all we can do is 'setUserValues' to {enabled: {}} + * @param {import("../../../../types/duckplayer").UserValues} userValues */ - setAlwaysOpen () { - Comms.messaging?.setUserValues({ - overlayInteracted: false, - privatePlayerMode: { enabled: {} } - }) + setUserValues (userValues) { + return this.messaging.request('setUserValues', userValues) } -} -const Setting = { /** - * Returns the checkbox - * @returns {HTMLInputElement} + * For platforms that require a message to open settings */ - settingsIcon: () => { - // @ts-expect-error - Type 'HTMLElement | null' is not assignable to type 'HTMLElement'. - return document.querySelector('[aria-label="Open Settings"]') - }, - /** - * Returns the checkbox - * @returns {HTMLInputElement} - */ - checkbox: () => { - // @ts-expect-error - Type 'HTMLElement | null' is not assignable to type 'HTMLElement'. - return document.querySelector('#setting') - }, - - /** - * Returns the settings label - * @returns {HTMLElement} - */ - container: () => { - // @ts-expect-error - Type 'HTMLElement | null' is not assignable to type 'HTMLElement'. - return document.querySelector('.setting-container') - }, - - /** - * Set the value of the checkbox - * 1. Set the actual 'checked' property of the checkbox - * 2. Update the toggle with the correct classes - * @param {boolean} value - */ - set: (value) => { - Setting.checkbox().checked = value - }, - - /** - * Returns whether checkbox isChecked - * @returns {boolean} - */ - isChecked: () => { - return Setting.checkbox().checked - }, - - /** - * Sets the state of the setting immediately - * @param {boolean} value - */ - setState: (value) => { - Setting.toggleAnimatable(false) - Setting.toggleVisibility(!value) - Setting.set(value) - }, - - /** - * Update the checkbox value and send the new setting value to native - * @param {boolean} checked - */ - updateAndSend: (checked) => { - if (checked) { - setTimeout(() => { - if (Setting.isChecked()) { - Setting.toggleAnimatable(true) - Setting.toggleVisibility(false) - Setting.higlightSettingsButton() - - // NATIVE NOTE: Setting is sent to native after animation is done - // this is because as soon as native receives the updated setting - // it also sends out a message to all opened PPPs to set the - // setting instantly. We don't want to do that for _this_ window, this - // is the quickest way of fixing that issue. - setTimeout(() => { - Comms.setAlwaysOpen() - }, 300) // Should match slide in CSS time - } - }, 800) // Wait a bit to allow for user mis-clicks - } - }, - - /** - * Toggle visibility of the entire settings container - * @param {boolean} visible - */ - toggleVisibility: (visible) => { - Setting.container()?.classList?.toggle('invisible', !visible) - }, - - /** - * Toggles whether the settings container should be animatable. It should only be so in anticipation - * of user action (clicking the checkbox) - * @param {boolean} animatable - */ - toggleAnimatable: (animatable) => { - Setting.container()?.classList?.toggle('animatable', animatable) - }, - - /** - * A nice touch to slightly highlight the settings button while the - * settings container is animating/sliding in behind it. - */ - higlightSettingsButton: () => { - // @ts-expect-error - Object is possibly 'null'. - const openSettingsClasses = document.querySelector('.open-settings').classList - - openSettingsClasses.add('active') - - setTimeout(() => { - openSettingsClasses.remove('active') - }, 300 + 100) // match .animatable css - }, - - /** - * Initializes the setting checkbox: - * 1. Listens for (user) changes on the actual checkbox - * 2. Listens for to clicks on the checkbox text - * @param {object} opts - * @param {string} opts.settingsUrl - */ - init: (opts) => { - const checkbox = Setting.checkbox() - - checkbox.addEventListener('change', () => { - Setting.updateAndSend(checkbox.checked) - }) - - const settingsIcon = Setting.settingsIcon() - - // windows settings - we will need to alter for other platforms. - settingsIcon.setAttribute('href', opts.settingsUrl) + openSettings () { + return this.messaging.notify('openSettings') } -} -const PlayOnYouTube = { /** - * Returns the YouTube button - * @returns {HTMLElement} + * For platforms that require a message to open info modal */ - button: () => { - // @ts-expect-error - Type 'HTMLElement | null' is not assignable to type 'HTMLElement'. - return document.querySelector('.play-on-youtube') - }, + openInfo () { + return this.messaging.notify('openInfo') + } /** - * If there is a valid video id, set the 'href' of the YouTube button to the - * video link url + * This is a subscription that we set up when the page loads. + * We use this value to show/hide the checkboxes. * - * @param {object} opts - * @param {string} opts.base + * **Integration NOTE**: Native platforms should always send this at least once on initial page load. + * + * - See {@link Messaging.SubscriptionEvent} for details on each value of this message + * + * ```json + * // the payload that we receive should look like this + * { + * "context": "specialPages", + * "featureName": "duckPlayerPage", + * "subscriptionName": "onUserValuesChanged", + * "params": { + * "overlayInteracted": false, + * "privatePlayerMode": { + * "enabled": {} + * } + * } + * } + * ``` + * + * @param {(value: import("../../../../types/duckplayer").UserValues) => void} cb */ - init: (opts) => { - const validVideoId = Comms.getValidVideoId() - const timestamp = Comms.getSanitizedTimestamp() - - if (validVideoId) { - const url = new URL(opts.base) - - url.searchParams.set('v', validVideoId) - - if (timestamp) { - url.searchParams.set('t', timestamp + 's') - } - - PlayOnYouTube.button().setAttribute('href', url.href) - } + onUserValuesChanged (cb) { + return this.messaging.subscribe('onUserValuesChanged', cb) } -} - -const Tooltip = { - visible: false, - - /** - * Returns the (i)-icon - * @returns {HTMLElement} - */ - icon: () => { - // @ts-expect-error - Type 'HTMLElement | null' is not assignable to type 'HTMLElement'. - return document.querySelector('.info-icon') - }, - - /** - * Returns the tooltip - * @returns {HTMLElement} - */ - tooltip: () => { - // @ts-expect-error - Type 'HTMLElement | null' is not assignable to type 'HTMLElement'. - return document.querySelector('.info-icon-tooltip') - }, /** - * Toggles visibility of tooltip - * @param {boolean} show + * This will be sent if the application has loaded, but a client-side error + * has occurred that cannot be recovered from + * @param {{message: string}} params */ - toggle: (show) => { - Tooltip.tooltip()?.classList?.toggle('above', Tooltip.isCloseToBottom()) - Tooltip.tooltip()?.classList?.toggle('visible', show) - Tooltip.visible = show - }, - - /** - * Returns whether tooltip is too close to bottom, used for positioning it above - * the icon when this happens - * @returns {boolean} - */ - isCloseToBottom: () => { - const icon = Tooltip.icon() - const rect = icon && icon.getBoundingClientRect() - - if (!rect || !rect.top) { - return false - } - - const iconTop = rect.top + window.scrollY - const spaceBelowIcon = window.innerHeight - iconTop - - if (spaceBelowIcon < 125) { - return true - } - - return false - }, - - /** - * Sets up the tooltip to show/hide based on icon hover - */ - init: () => { - Tooltip.icon().addEventListener('mouseenter', () => { - Tooltip.toggle(true) - }) - - Tooltip.icon().addEventListener('mouseleave', () => { - Tooltip.toggle(false) - }) + reportPageException (params) { + this.messaging.notify('reportPageException', params) } -} - -const MouseMove = { - /** - * How long to wait (inactivity) before fading out the content below the player - */ - limit: 1500, - - /** - * Transition time - needs to match toolbar value in CSS. - */ - fadeTransitionTime: 500, - - /** - * Internal, used for timeout and state. - */ - timer: null, - isFaded: false, - isHoveringContent: false, - - /** - * Fade out content below player in case there is mouse inactivity - * after the MouseMove.limit - */ - init: () => { - document.addEventListener('mousemove', MouseMove.handleFadeState) - - // Don't count clicks as inactivity and reset the timer. - document.addEventListener('mousedown', MouseMove.handleFadeState) - - // Start watching for inactivity as soon as page is loaded - there might not be any - // mouse interactions etc - MouseMove.handleFadeState() - - MouseMove.contentHover().addEventListener('mouseenter', () => { - MouseMove.isHoveringContent = true - }) - - MouseMove.contentHover().addEventListener('mouseleave', () => { - MouseMove.isHoveringContent = false - }) - }, /** - * Watch for inactivity and toggle toolbar accordingly + * This will be sent if the application fails to load. + * @param {{message: string}} params */ - handleFadeState: () => { - if (MouseMove.timer) { - clearTimeout(MouseMove.timer) - } - - if (MouseMove.isFaded) { - MouseMove.fadeInContent() - } - - // @ts-expect-error - Type 'Timeout' is not assignable to type 'null'. - MouseMove.timer = setTimeout(() => { - // Only fade out if user is not hovering content or tooltip is shown - if (!MouseMove.isHoveringContent && !Tooltip.visible) { - MouseMove.fadeOutContent() - } - }, MouseMove.limit) - }, - - /** - * Return the background element - * @returns {HTMLElement} - */ - bg: () => { - // @ts-expect-error - Type 'HTMLElement | null' is not assignable to type 'HTMLElement'. - return document.querySelector('.bg') - }, - - /** - * Returns all content hover container, used for detecting - * hovers on the video player - * @returns {HTMLElement} - */ - contentHover: () => { - // @ts-expect-error - Type 'HTMLElement | null' is not assignable to type 'HTMLElement'. - return document.querySelector('.content-hover') - }, - - /** - * Fades out content - */ - fadeOutContent: () => { - MouseMove.updateContent(true) - }, - - /** - * Fades in content - */ - fadeInContent: () => { - MouseMove.updateContent(false) - }, - - /** - * Updates the faded state of the content below the player - */ - updateContent: (isFaded) => { - document.body?.classList?.toggle('faded', isFaded) - - setTimeout(() => { - MouseMove.isFaded = isFaded - }, MouseMove.fadeTransitionTime) + reportInitException (params) { + this.messaging.notify('reportInitException', params) } } -/** - * Initializes all parts of the page on load. - */ -document.addEventListener('DOMContentLoaded', async () => { - Setting.init({ - settingsUrl: settingsUrl(import.meta.injectName) - }) - - const messaging = createSpecialPageMessaging({ - injectName: import.meta.injectName, - env: import.meta.env, - pageName: 'duckPlayerPage' - }) +const baseEnvironment = new Environment() + .withInjectName(document.documentElement.dataset.platform) + .withEnv(import.meta.env) - const page = new DuckPlayerPageMessages(messaging, import.meta.injectName) - - const result = await Comms.init(page) - - if (!('value' in result)) { - console.warn('cannot continue as the initialSetup call didnt complete') - console.error(result.error) - return - } - - VideoPlayer.init({ - base: baseUrl(import.meta.injectName), - env: import.meta.env, - settings: result.value.settings - }) - Tooltip.init() - PlayOnYouTube.init({ - base: baseUrl(import.meta.injectName) - }) - MouseMove.init() +const messaging = createSpecialPageMessaging({ + injectName: baseEnvironment.injectName, + env: baseEnvironment.env, + pageName: 'duckPlayerPage' }) -/** - * @param {ImportMeta['injectName']} injectName - */ -function baseUrl (injectName) { - switch (injectName) { - // this is different on Windows to allow the native side to intercept the navigation more easily - case 'windows': return 'duck://player/openInYoutube' - default: return 'https://www.youtube.com/watch' - } -} +const example = new DuckplayerPage(messaging, import.meta.injectName) -/** - * @param {ImportMeta['injectName']} injectName - */ -function settingsUrl (injectName) { - switch (injectName) { - // this is different on Windows to allow the native side to intercept the navigation more easily - case 'windows': return 'duck://settings/duckplayer' - default: return 'about:preferences/duckplayer' - } -} +init(example, baseEnvironment).catch(e => { + // messages. + console.error(e) + const msg = typeof e?.message === 'string' ? e.message : 'unknown init error' + example.reportInitException({ message: msg }) +}) initStorage() diff --git a/packages/special-pages/pages/duckplayer/src/js/inline.js b/packages/special-pages/pages/duckplayer/src/js/inline.js new file mode 100644 index 000000000..ea0da5f10 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/src/js/inline.js @@ -0,0 +1,22 @@ +/** + * This script is designed to be run before the application loads, use it to set values + * that might be needed in CSS or JS + */ + +const param = new URLSearchParams(window.location.search).get('platform') + +if (isAllowed(param)) { + document.documentElement.dataset.platform = String(param) +} else { + document.documentElement.dataset.platform = import.meta.injectName +} + +/** + * @param {any} input + * @returns {input is ImportMeta['injectName']} + */ +function isAllowed (input) { + /** @type {ImportMeta['injectName'][]} */ + const allowed = ['windows', 'apple', 'integration'] + return allowed.includes(input) +} diff --git a/packages/special-pages/pages/duckplayer/src/js/messages.example.js b/packages/special-pages/pages/duckplayer/src/js/messages.example.js deleted file mode 100644 index 1ce11d984..000000000 --- a/packages/special-pages/pages/duckplayer/src/js/messages.example.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * The user wishes to enable DuckPlayer - * @satisfies {import("./messages").UserValues} - */ -const enabled = { - privatePlayerMode: { enabled: {} }, - overlayInteracted: false -} - -console.log(enabled) - -/** - * The user wishes to disable DuckPlayer - * @satisfies {import("./messages").UserValues} - */ -const disabled = { - privatePlayerMode: { disabled: {} }, - overlayInteracted: false -} - -console.log(disabled) - -/** - * The user wishes for overlays to always show - * @satisfies {import("./messages").UserValues} - */ -const alwaysAsk = { - privatePlayerMode: { alwaysAsk: {} }, - overlayInteracted: false -} - -console.log(alwaysAsk) - -/** - * The user wishes only for small overlays to show, not the blocking video ones - * @satisfies {import("./messages").UserValues} - */ -const alwaysAskRemembered = { - privatePlayerMode: { alwaysAsk: {} }, - overlayInteracted: true -} - -console.log(alwaysAskRemembered) diff --git a/packages/special-pages/pages/duckplayer/src/js/messages.js b/packages/special-pages/pages/duckplayer/src/js/messages.js deleted file mode 100644 index 60b35fcbd..000000000 --- a/packages/special-pages/pages/duckplayer/src/js/messages.js +++ /dev/null @@ -1,150 +0,0 @@ -/** - * @typedef {object} InitialSetup - The initial payload used to communicate render-blocking information - * @property {UserValues} userValues - The state of the user values - * @property {DuckPlayerPageSettings} settings - Additional settings - */ - -/** - * Notifications or requests that the Duck Player Page will - * send to the native side - */ -export class DuckPlayerPageMessages { - /** - * @param {import("@duckduckgo/messaging").Messaging} messaging - * @param {ImportMeta["injectName"]} injectName - * @internal - */ - constructor (messaging, injectName) { - /** - * @internal - */ - this.messaging = messaging - this.injectName = injectName - } - - /** - * This is sent when the user wants to set Duck Player as the default. - * - * @returns {Promise<InitialSetup>} params - */ - initialSetup () { - if (this.injectName === 'integration') { - return Promise.resolve({ - settings: { - pip: { - state: 'enabled' - } - }, - userValues: new UserValues({ - overlayInteracted: false, - privatePlayerMode: { alwaysAsk: {} } - }) - }) - } - return this.messaging.request('initialSetup') - } - - /** - * This is sent when the user wants to set Duck Player as the default. - * - * @param {UserValues} userValues - */ - setUserValues (userValues) { - return this.messaging.request('setUserValues', userValues) - } - - /** - * This is sent when the user wants to set Duck Player as the default. - * @return {Promise<UserValues>} - */ - getUserValues () { - if (this.injectName === 'integration') { - return Promise.resolve(new UserValues({ - overlayInteracted: false, - privatePlayerMode: { alwaysAsk: {} } - })) - } - return this.messaging.request('getUserValues') - } - - /** - * This is a subscription that we set up when the page loads. - * We use this value to show/hide the checkboxes. - * - * **Integration NOTE**: Native platforms should always send this at least once on initial page load. - * - * - See {@link Messaging.SubscriptionEvent} for details on each value of this message - * - See {@link UserValues} for details on the `params` - * - * ```json - * // the payload that we receive should look like this - * { - * "context": "specialPages", - * "featureName": "duckPlayerPage", - * "subscriptionName": "onUserValuesChanged", - * "params": { - * "overlayInteracted": false, - * "privatePlayerMode": { - * "enabled": {} - * } - * } - * } - * ``` - * - * @param {(value: UserValues) => void} cb - */ - onUserValuesChanged (cb) { - return this.messaging.subscribe('onUserValuesChanged', cb) - } -} - -/** - * This data structure is sent to enable user settings to be updated - * - * ```js - * [[include:packages/special-pages/pages/duckplayer/src/js/messages.example.js]]``` - */ -export class UserValues { - /** - * @param {object} params - * @param {{enabled: {}} | {disabled: {}} | {alwaysAsk: {}}} params.privatePlayerMode - * @param {boolean} params.overlayInteracted - */ - constructor (params) { - /** - * 'enabled' means 'always play in duck player' - * 'disabled' means 'never play in duck player' - * 'alwaysAsk' means 'show overlay prompts for using duck player' - * @type {{enabled: {}}|{disabled: {}}|{alwaysAsk: {}}} - */ - this.privatePlayerMode = params.privatePlayerMode - /** - * `true` when the user has asked to remember a previous choice - * - * `false` if they have never used the checkbox - * @type {boolean} - */ - this.overlayInteracted = params.overlayInteracted - } -} - -/** - * Sent in the initial page load request. Used to provide features toggles - * and other none-user-specific settings. - * - * Note: This will be improved soon with better remote config integration. - */ -export class DuckPlayerPageSettings { - /** - * @param {object} params - * @param {object} params.pip - * @param {"enabled" | "disabled"} params.pip.state - */ - constructor (params) { - /** - * 'enabled' means that the FE should show the PIP button - * 'disabled' means that the FE should never show it - */ - this.pip = params.pip - } -} diff --git a/packages/special-pages/pages/duckplayer/src/js/utils.js b/packages/special-pages/pages/duckplayer/src/js/utils.js index 07b5c0324..4a88871a9 100644 --- a/packages/special-pages/pages/duckplayer/src/js/utils.js +++ b/packages/special-pages/pages/duckplayer/src/js/utils.js @@ -28,3 +28,13 @@ export function createYoutubeURLForError (href, urlBase) { return url.toString() } + +/** + * @param {string|null|undefined} iframeTitle + * @return {string | null} + */ +export function getValidVideoTitle (iframeTitle) { + if (typeof iframeTitle !== 'string') return null + if (iframeTitle === 'YouTube') return null + return iframeTitle.replace(/ - YouTube$/g, '') +} diff --git a/packages/special-pages/pages/duckplayer/src/locales/en/duckplayer.json b/packages/special-pages/pages/duckplayer/src/locales/en/duckplayer.json new file mode 100644 index 000000000..ab7954072 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/src/locales/en/duckplayer.json @@ -0,0 +1,36 @@ +{ + "smartling": { + "string_format": "icu", + "translate_paths": [ + { + "path": "*/title", + "key": "{*}/title", + "instruction": "*/note" + } + ] + }, + "alwaysWatchHere": { + "title": "Always open YouTube videos here", + "note": "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled": { + "title": "Keep Duck Player turned on", + "note": "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton": { + "title": "Open Info", + "note": "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton": { + "title": "Open Settings", + "note": "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube": { + "title": "Watch on YouTube", + "note": "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError": { + "title": "<b>ERROR:</b> Invalid video id", + "note": "Shown when the page URL doesn't match a known video ID. Note for translators: The <b> tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + } +} diff --git a/packages/special-pages/pages/duckplayer/unit-tests/embed-settings.mjs b/packages/special-pages/pages/duckplayer/unit-tests/embed-settings.mjs new file mode 100644 index 000000000..fbf9083d4 --- /dev/null +++ b/packages/special-pages/pages/duckplayer/unit-tests/embed-settings.mjs @@ -0,0 +1,101 @@ +import { describe, it } from "node:test"; +import { deepEqual } from "node:assert/strict"; +import { EmbedSettings } from "../app/embed-settings.js"; + +describe('creates embed url', () => { + it('handles duck scheme', () => { + const actual = EmbedSettings.fromHref("duck://player/123")?.toEmbedUrl(); + const expected = 'https://www.youtube-nocookie.com/embed/123?iv_load_policy=1&autoplay=1&rel=0&modestbranding=1'; + deepEqual(actual, expected); + }) + it('handles duck scheme with timestamp', () => { + const actual = EmbedSettings.fromHref("duck://player/123?t=1h2m3s")?.toEmbedUrl(); + const expected = { + iv_load_policy: '1', + autoplay: '1', + rel: '0', + modestbranding: '1', + start: '3723' + } + if (!actual) throw new Error('unreachable') + const asParams = Object.fromEntries(new URL(actual).searchParams) + deepEqual(asParams, expected); + }) + it('handles duck scheme with videoID param', () => { + const actual = EmbedSettings.fromHref("duck://player?videoID=88YIfKLDdvM&t=1h2m3s")?.toEmbedUrl(); + const expected = { + iv_load_policy: '1', + autoplay: '1', + rel: '0', + modestbranding: '1', + start: '3723' + } + if (!actual) throw new Error('unreachable') + const asParams = Object.fromEntries(new URL(actual).searchParams) + deepEqual(asParams, expected); + }) + it('handles yt scheme', () => { + const actual = EmbedSettings.fromHref("https://youtube-nocookie.com/embed/88YIfKLDdvM?t=1h2m3s")?.toEmbedUrl(); + const expected = { + iv_load_policy: '1', + autoplay: '1', + rel: '0', + modestbranding: '1', + start: '3723' + } + if (!actual) throw new Error('unreachable') + const asParams = Object.fromEntries(new URL(actual).searchParams) + deepEqual(asParams, expected); + }) + it('handles invalid timestamp', () => { + const actual = EmbedSettings.fromHref("https://youtube-nocookie.com/embed/88YIfKLDdvM?t=abc")?.toEmbedUrl(); + const expected = { + iv_load_policy: '1', + autoplay: '1', + rel: '0', + modestbranding: '1' + } + if (!actual) throw new Error('unreachable') + const asParams = Object.fromEntries(new URL(actual).searchParams) + deepEqual(asParams, expected); + }) + it('can be muted', () => { + const embed = EmbedSettings.fromHref("https://youtube-nocookie.com/embed/88YIfKLDdvM?t=abc"); + const actual = embed?.withMuted(true).toEmbedUrl(); + const expected = { + iv_load_policy: '1', + autoplay: '1', + rel: '0', + modestbranding: '1', + muted: '1' + } + if (!actual) throw new Error('unreachable') + const asParams = Object.fromEntries(new URL(actual).searchParams) + deepEqual(asParams, expected); + }) + it('can disable autoplay', () => { + const embed = EmbedSettings.fromHref("https://youtube-nocookie.com/embed/88YIfKLDdvM?t=abc"); + const actual = embed?.withAutoplay(false).toEmbedUrl(); + const expected = { + iv_load_policy: '1', + rel: '0', + modestbranding: '1', + } + if (!actual) throw new Error('unreachable') + const asParams = Object.fromEntries(new URL(actual).searchParams) + deepEqual(asParams, expected); + }) + it('can respects default autoplay settings (stays on when the setting is undefined)', () => { + const embed = EmbedSettings.fromHref("https://youtube-nocookie.com/embed/88YIfKLDdvM?t=abc"); + const actual = embed?.withAutoplay(undefined).toEmbedUrl(); + const expected = { + iv_load_policy: '1', + autoplay: '1', + rel: '0', + modestbranding: '1', + } + if (!actual) throw new Error('unreachable') + const asParams = Object.fromEntries(new URL(actual).searchParams) + deepEqual(asParams, expected); + }) +}) diff --git a/packages/special-pages/playwright.config.js b/packages/special-pages/playwright.config.js index 7ffa4ef69..75da5aaea 100644 --- a/packages/special-pages/playwright.config.js +++ b/packages/special-pages/playwright.config.js @@ -6,6 +6,7 @@ export default defineConfig({ name: 'windows', testMatch: [ 'duckplayer.spec.js', + 'duckplayer-screenshots.spec.js', 'onboarding.spec.js' ], use: { @@ -18,6 +19,7 @@ export default defineConfig({ name: 'macos', testMatch: [ 'duckplayer.spec.js', + 'duckplayer-screenshots.spec.js', 'onboarding.spec.js', 'sslerror.spec.js', 'release-notes.spec.js' diff --git a/packages/special-pages/shared/components/EnvironmentProvider.js b/packages/special-pages/shared/components/EnvironmentProvider.js index 5f4c8919d..0f818c15a 100644 --- a/packages/special-pages/shared/components/EnvironmentProvider.js +++ b/packages/special-pages/shared/components/EnvironmentProvider.js @@ -35,14 +35,26 @@ export function EnvironmentProvider ({ children, debugState, willThrow = false, useEffect(() => { // media query const mediaQueryList = window.matchMedia(REDUCED_MOTION_QUERY) - const listener = (e) => setReducedMotion(e.matches) + + const listener = (e) => setter(e.matches) mediaQueryList.addEventListener('change', listener) + // set the initial value + setter(mediaQueryList.matches) + + /** + * @type {(value: boolean) => void} value + */ + function setter (value) { + document.documentElement.dataset.reducedMotion = String(value) + setReducedMotion(value) + } + // toggle events on window window.addEventListener('toggle-reduced-motion', () => { - setReducedMotion(true) - document.documentElement.dataset.reducedMotion = String(true) + setter(true) }) + return () => mediaQueryList.removeEventListener('change', listener) }, []) @@ -84,3 +96,11 @@ export function UpdateEnvironment ({ search }) { export function useEnv () { return useContext(EnvironmentContext) } + +export function WillThrow () { + const env = useEnv() + if (env.willThrow) { + throw new Error('Simulated Exception') + } + return null +} diff --git a/packages/special-pages/tests/duckplayer-screenshots.spec.js b/packages/special-pages/tests/duckplayer-screenshots.spec.js new file mode 100644 index 000000000..7da7c5322 --- /dev/null +++ b/packages/special-pages/tests/duckplayer-screenshots.spec.js @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' +import { DuckPlayerPage } from './page-objects/duck-player.js' + +test.describe('screenshots @screenshots', () => { + test.skip(process.env.CI === 'true') + test('regular layout', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo) + // load as normal + await duckplayer.openWithVideoID() + await duckplayer.hasLoadedIframe() + await expect(page).toHaveScreenshot('regular-layout.png', { maxDiffPixels: 20 }) + }) + test('layout when enabled', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo) + // load as normal + duckplayer.playerIsEnabled() + await duckplayer.openWithVideoID() + await duckplayer.hasLoadedIframe() + await duckplayer.didReceiveFirstSettingsUpdate() + await expect(page).toHaveScreenshot('enabled-layout.png', { maxDiffPixels: 20 }) + }) + test('player error', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo) + await duckplayer.openWithVideoID('€%dd#"') + await duckplayer.hasShownErrorMessage() + await expect(page).toHaveScreenshot('error-layout.png', { maxDiffPixels: 20 }) + }) + test('tooltip shown on hover', async ({ page }, workerInfo) => { + test.skip(isMobile(workerInfo)) + const duckplayer = DuckPlayerPage.create(page, workerInfo) + await duckplayer.openWithoutFocusMode() + await duckplayer.hasLoadedIframe() + + await duckplayer.infoTooltipIsShowsOnFocus() + await expect(page).toHaveScreenshot('tooltip.png', { maxDiffPixels: 20 }) + await duckplayer.infoTooltipHides() + }) +}) + +/** + * @param {import("@playwright/test").TestInfo} testInfo + */ +function isMobile (testInfo) { + const u = /** @type {any} */(testInfo.project.use) + return u?.platform === 'android' || u?.platform === '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 new file mode 100644 index 000000000..edbb9894b Binary files /dev/null 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 new file mode 100644 index 000000000..1fc263881 Binary files /dev/null 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 new file mode 100644 index 000000000..a32adb860 Binary files /dev/null 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/enabled-layout-macos-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-macos-darwin.png new file mode 100644 index 000000000..d96bffb68 Binary files /dev/null and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-macos-darwin.png differ diff --git a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-windows-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-windows-darwin.png new file mode 100644 index 000000000..c5908691b Binary files /dev/null and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-windows-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 new file mode 100644 index 000000000..839517edf Binary files /dev/null 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 new file mode 100644 index 000000000..5ff99ff87 Binary files /dev/null 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 new file mode 100644 index 000000000..d747a6bf0 Binary files /dev/null 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/error-layout-macos-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-macos-darwin.png new file mode 100644 index 000000000..0a356c65c Binary files /dev/null and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-macos-darwin.png differ diff --git a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-windows-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-windows-darwin.png new file mode 100644 index 000000000..62f140af5 Binary files /dev/null and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/error-layout-windows-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 new file mode 100644 index 000000000..db30f8432 Binary files /dev/null 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 new file mode 100644 index 000000000..9d071951e Binary files /dev/null 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 new file mode 100644 index 000000000..e213dc044 Binary files /dev/null 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-screenshots.spec.js-snapshots/regular-layout-macos-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-macos-darwin.png new file mode 100644 index 000000000..c0754b4fa Binary files /dev/null and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-macos-darwin.png differ diff --git a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-windows-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-windows-darwin.png new file mode 100644 index 000000000..79d25b5aa Binary files /dev/null and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-windows-darwin.png differ diff --git a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/tooltip-macos-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/tooltip-macos-darwin.png new file mode 100644 index 000000000..9defc83b2 Binary files /dev/null and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/tooltip-macos-darwin.png differ diff --git a/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/tooltip-windows-darwin.png b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/tooltip-windows-darwin.png new file mode 100644 index 000000000..1c1e1555d Binary files /dev/null and b/packages/special-pages/tests/duckplayer-screenshots.spec.js-snapshots/tooltip-windows-darwin.png differ diff --git a/packages/special-pages/tests/duckplayer.spec.js b/packages/special-pages/tests/duckplayer.spec.js index 32ccd5a4c..4130e9646 100644 --- a/packages/special-pages/tests/duckplayer.spec.js +++ b/packages/special-pages/tests/duckplayer.spec.js @@ -13,7 +13,7 @@ test.describe('duckplayer iframe', () => { await duckplayer.openWithVideoID('this_has_too_many_chars') await duckplayer.hasLoadedIframe('this_has_to') }) - test.skip('reflects title from embed', async ({ page }, workerInfo) => { + test('reflects title from embed', async ({ page }, workerInfo) => { const duckplayer = DuckPlayerPage.create(page, workerInfo) await duckplayer.openWithVideoID() await duckplayer.hasTheSameTitleAsEmbed() @@ -50,10 +50,29 @@ test.describe('duckplayer iframe', () => { await duckplayer.openWithVideoID() await duckplayer.allowsPopups() }) + test('pip setting', async ({ page }, workerInfo) => { + test.skip(isMobile(workerInfo)) + const duckplayer = DuckPlayerPage.create(page, workerInfo) + // load as normal + duckplayer.pipSettingIs({ state: 'enabled' }) + await duckplayer.openWithVideoID() + await duckplayer.hasLoadedIframe() + await duckplayer.hasPipButton() + }) + test('pip setting disabled', async ({ page }, workerInfo) => { + test.skip(isMobile(workerInfo)) + const duckplayer = DuckPlayerPage.create(page, workerInfo) + // load as normal + duckplayer.pipSettingIs({ state: 'disabled' }) + await duckplayer.openWithVideoID() + await duckplayer.hasLoadedIframe() + await duckplayer.pipButtonIsAbsent() + }) }) test.describe('duckplayer toolbar', () => { test('hides toolbar based on user activity', async ({ page }, workerInfo) => { + test.skip(isMobile(workerInfo)) const duckplayer = DuckPlayerPage.create(page, workerInfo) await duckplayer.openWithVideoID() await duckplayer.hasLoadedIframe() @@ -68,37 +87,62 @@ test.describe('duckplayer toolbar', () => { await page.mouse.move(10, 10) await duckplayer.toolbarIsVisible() }) - test('tooltip shown on hover', async ({ page }, workerInfo) => { + test('clicking on cog icon opens settings', async ({ page }, workerInfo) => { + test.skip(isMobile(workerInfo)) const duckplayer = DuckPlayerPage.create(page, workerInfo) await duckplayer.openWithVideoID() await duckplayer.hasLoadedIframe() - - // 1. Show tooltip on hover of info icon - await duckplayer.hoverInfoIcon() - await duckplayer.infoTooltipIsShown() - - // 2. Hide tooltip when mouse leaves - await page.mouse.move(1, 1) - await duckplayer.infoTooltipIsHidden() + await duckplayer.opensSettingsInNewTab() }) - test('clicking on cog icon opens settings', async ({ page }, workerInfo) => { + test('opening in youtube', async ({ page }, workerInfo) => { + test.skip(isMobile(workerInfo)) const duckplayer = DuckPlayerPage.create(page, workerInfo) await duckplayer.openWithVideoID() await duckplayer.hasLoadedIframe() + await duckplayer.opensInYoutube() + }) +}) - await page.mouse.move(1, 1) - await duckplayer.opensSettingsInNewTab() +test.describe('duckplayer mobile settings', () => { + test('open setting on tap', async ({ page }, workerInfo) => { + test.skip(isDesktop(workerInfo)) + const duckplayer = DuckPlayerPage.create(page, workerInfo) + await duckplayer.openWithVideoID() + await duckplayer.hasLoadedIframe() + await duckplayer.openSettings() + await duckplayer.didOpenMobileSettings() }) - test('opening in youtube', async ({ page }, workerInfo) => { + test('open info modal on tap', async ({ page }, workerInfo) => { + test.skip(isDesktop(workerInfo)) const duckplayer = DuckPlayerPage.create(page, workerInfo) await duckplayer.openWithVideoID() await duckplayer.hasLoadedIframe() - await duckplayer.opensInYoutube() + await duckplayer.openInfo() + await duckplayer.didOpenInfo() + }) + test('open on Youtube on tap', async ({ page }, workerInfo) => { + test.skip(isDesktop(workerInfo)) + const duckplayer = DuckPlayerPage.create(page, workerInfo) + await duckplayer.openWithVideoID() + await duckplayer.hasLoadedIframe() + await duckplayer.watchOnYoutube() + await duckplayer.didWatchOnYoutube() + }) + test('toggles the switch', async ({ page }, workerInfo) => { + test.skip(isDesktop(workerInfo)) + const duckplayer = DuckPlayerPage.create(page, workerInfo) + await duckplayer.openWithVideoID() + await duckplayer.hasLoadedIframe() + await duckplayer.reducedMotion() + await page.getByLabel('Keep Duck Player turned on').click() // can't 'check' here + await page.getByLabel('Keep Duck Player turned on').waitFor({ state: 'hidden' }) + await duckplayer.sentUpdatedSettings() }) }) -test.describe('duckplayer settings', () => { +test.describe('duckplayer desktop settings', () => { test('always open setting', async ({ page }, workerInfo) => { + test.skip(isMobile(workerInfo)) const duckplayer = DuckPlayerPage.create(page, workerInfo) // load as normal await duckplayer.openWithVideoID() @@ -114,7 +158,21 @@ test.describe('duckplayer settings', () => { await duckplayer.toggleAlwaysOpenSetting() await duckplayer.sentUpdatedSettings() }) + test('always open setting (reduced motion)', async ({ page }, workerInfo) => { + test.skip(isMobile(workerInfo)) + const duckplayer = DuckPlayerPage.create(page, workerInfo) + await duckplayer.reducedMotion() + await duckplayer.openWithVideoID() + await duckplayer.hasLoadedIframe() + await duckplayer.didReceiveFirstSettingsUpdate() + await duckplayer.settingsAreVisible() + await page.mouse.move(1, 1) + await page.mouse.move(10, 10) + await duckplayer.toggleAlwaysOpenSetting() + await duckplayer.sentUpdatedSettings() + }) test('when a new value arrives via subscription', async ({ page }, workerInfo) => { + test.skip(isMobile(workerInfo)) const duckplayer = DuckPlayerPage.create(page, workerInfo) // load as normal await duckplayer.openWithVideoID() @@ -129,3 +187,26 @@ test.describe('duckplayer settings', () => { await duckplayer.checkboxWasChecked() }) }) + +test.describe('reporting exceptions', () => { + test('regular layout', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo) + // load as normal + await duckplayer.openWithException() + await duckplayer.showsErrorMessage() + }) +}) + +/** + * @param {import("@playwright/test").TestInfo} testInfo + */ +function isMobile (testInfo) { + const u = /** @type {any} */(testInfo.project.use) + return u?.platform === 'android' || u?.platform === 'ios' +} +/** + * @param {import("@playwright/test").TestInfo} testInfo + */ +function isDesktop (testInfo) { + return !isMobile(testInfo) +} diff --git a/packages/special-pages/tests/page-objects/duck-player.js b/packages/special-pages/tests/page-objects/duck-player.js index 5e1eb49fd..0343323c0 100644 --- a/packages/special-pages/tests/page-objects/duck-player.js +++ b/packages/special-pages/tests/page-objects/duck-player.js @@ -6,6 +6,7 @@ import { perPlatform } from '../../../../integration-test/playwright/type-helper const MOCK_VIDEO_ID = 'VIDEO_ID' const MOCK_VIDEO_TITLE = 'Embedded Video - YouTube' const youtubeEmbed = (id) => 'https://www.youtube-nocookie.com/embed/' + id + '?iv_load_policy=1&autoplay=1&rel=0&modestbranding=1' +const youtubeEmbedIOS = (id) => 'https://www.youtube-nocookie.com/embed/' + id + '?iv_load_policy=1&autoplay=1&muted=1&rel=0&modestbranding=1' const html = { unsupported: `<html><head><title>${MOCK_VIDEO_TITLE}</title></head> <body> @@ -44,8 +45,8 @@ export class DuckPlayerPage { env: 'development' }) // default mocks - just enough to render the first page without error - this.mocks.defaultResponses({ - /** @type {Awaited<ReturnType<import("../../pages/duckplayer/src/js/index.js").DuckPlayerPageMessages['initialSetup']>>} */ + this.defaults = { + // /** @type {Awaited<ReturnType<import("../../pages/duckplayer/src/js/index.js").DuckPlayerPageMessages['initialSetup']>>} */ initialSetup: { settings: { pip: { @@ -55,15 +56,23 @@ export class DuckPlayerPage { userValues: { privatePlayerMode: { alwaysAsk: {} }, overlayInteracted: false - } - + }, + locale: 'en', + env: 'development', + platform: this.platform.name === 'windows' ? undefined : { name: this.platform.name } }, - /** @type {import("../../pages/duckplayer/src/js/index.js").UserValues} */ + /** @type {import('../../types/duckplayer.js').UserValues} */ getUserValues: { privatePlayerMode: { alwaysAsk: {} }, overlayInteracted: false + }, + /** @type {import('../../types/duckplayer.js').UserValues} */ + setUserValues: { + privatePlayerMode: { enabled: {} }, + overlayInteracted: false } - }) + } + this.mocks.defaultResponses(this.defaults) } /** @@ -79,6 +88,32 @@ export class DuckPlayerPage { await this.page.goto(url) } + async reducedMotion () { + await this.page.emulateMedia({ reducedMotion: 'reduce' }) + } + + playerIsEnabled () { + this.mocks.defaultResponses({ + ...this.defaults, + initialSetup: { + ...this.defaults.initialSetup, + userValues: { + privatePlayerMode: { enabled: {} }, + overlayInteracted: false + } + } + }) + } + + /** + * @param {{ state: 'enabled' | 'disabled' }} setting + */ + pipSettingIs (setting) { + const clone = structuredClone(this.defaults) + clone.initialSetup.settings.pip = setting + this.mocks.defaultResponses(clone) + } + /** * We don't need to actually load the content for these tests. * By mocking the response, we make the tests about 10x faster and also ensure they work offline. @@ -110,16 +145,40 @@ export class DuckPlayerPage { contentType: 'text/html' }) } + const mp4VideoPlaceholderAsDataURI = 'data:video/mp4;base64,AAAAHGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAwFtZGF0AAACogYF//+b3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE1MiByMjg1NCBlMjA5YTFjIC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAxNyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzowMTMzIHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5nZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEgZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02MyBsb29rYWhlYWRfdGhyZWFkcz0yIHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9MCBibHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVzPTMgYl9weXJhbWlkPTIgYl9hZGFwdD1xLTIgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtleWludD0yNTAga2V5aW50X21pbj0yNSBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmM9bG9va2FoZWFkIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCB2YnY9MCBjbG9zZWRfZ29wPTAgY3V0X3Rocm91Z2g9MCAnbm8tZGlndHMuanBnLTFgcC1mbHWinS3SlB8AP0AAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABSAAAAAAAAAAAAAAAAAAABBZHJ0AAAAAAAAAA==' return request.fulfill({ status: 200, contentType: 'text/html', body: ` <html> - <head><title>${MOCK_VIDEO_TITLE}</title></head> + <head> + <title>${MOCK_VIDEO_TITLE}</title> + <style> + html, body { + margin: 0; + padding: 0; + height: 100%; + width: 100%; + background: black; + color: white; + display: grid; + align-items: center; + justify-items: center; + } + .ytp-pip-button { + -webkit-appearance: none; + border: 0; + box-shadow: none; + background: green; + display: none; + } + </style> + </head> <body>Video Embed <div id="player"> <video src="${mp4VideoPlaceholderAsDataURI}"></video> + <button class="ytp-pip-button">PIP</button> </div> </body> </html>` @@ -144,6 +203,24 @@ export class DuckPlayerPage { await this.openPage(params) } + async openWithException () { + const params = new URLSearchParams({ willThrow: String(true) }) + await this.openPage(params) + } + + /** + * @param {string} [videoID] + * @returns {Promise<void>} + */ + async openWithoutFocusMode (videoID = MOCK_VIDEO_ID) { + const params = new URLSearchParams({ videoID, focusMode: 'disabled' }) + await this.openPage(params) + } + + async showsErrorMessage () { + await expect(this.page.locator('body')).toContainText('Something went wrong!') + } + /** * @param {string} timestamp * @param {string} [videoID] @@ -159,7 +236,11 @@ export class DuckPlayerPage { * @returns {Promise<void>} */ async hasLoadedIframe (videoID = MOCK_VIDEO_ID) { - const expected = new URL(youtubeEmbed(videoID)) + const url = this.platform.name === 'ios' + ? youtubeEmbedIOS(videoID) + : youtubeEmbed(videoID) + + const expected = new URL(url) await expect(this.page.locator('iframe')) .toHaveAttribute('src', expected.toString()) } @@ -169,6 +250,15 @@ export class DuckPlayerPage { await expect(this.page.locator('body')).toHaveAttribute('data-video-state', 'loaded+focussed') } + async hasPipButton () { + await this.page.frameLocator('iframe').getByRole('button', { name: 'PIP' }).click() + } + + async pipButtonIsAbsent () { + const count = await this.page.frameLocator('iframe').getByRole('button', { name: 'PIP' }).count() + expect(count).toBe(0) + } + async hasTheSameTitleAsEmbed () { const expected = 'Duck Player - Embedded Video' @@ -186,7 +276,10 @@ export class DuckPlayerPage { */ async videoStartsAtTimestamp (seconds, videoID = MOCK_VIDEO_ID) { // construct the expected url - const youtubeSrc = new URL(youtubeEmbed(videoID)) + const url = this.platform.name === 'ios' + ? youtubeEmbedIOS(videoID) + : youtubeEmbed(videoID) + const youtubeSrc = new URL(url) youtubeSrc.searchParams.set('start', seconds) @@ -206,48 +299,28 @@ export class DuckPlayerPage { } async toolbarIsVisible () { - await expect(this.page.locator('.toolbar')).not.toHaveCSS('opacity', '0') + await this.page.getByText('Always open YouTube videos here').waitFor({ state: 'visible' }) } async toolbarIsHidden () { - await expect(this.page.locator('.toolbar')).toHaveCSS('opacity', '0') - } - - async hoverInfoIcon () { - await this.page.locator('.info-icon-container img').hover() + await this.page.getByText('Always open YouTube videos here').waitFor({ state: 'hidden' }) } - async infoTooltipIsShown () { - await expect(this.page.locator('.info-icon-tooltip')).toBeVisible() + async infoTooltipIsShowsOnFocus () { + await this.page.getByLabel('Info').hover() + await expect(this.page.getByRole('tooltip')).toBeVisible() } - async infoTooltipIsHidden () { - await expect(this.page.locator('.info-icon-tooltip')).toBeHidden() + async infoTooltipHides () { + await this.page.locator('body').hover() + await expect(this.page.getByRole('tooltip')).toBeHidden() } async opensSettingsInNewTab () { - const newTab = new Promise(resolve => { - // on pages with about:preferences it will launch a new tab - this.page.context().on('page', resolve) - - // on windows it will be a failed request - this.page.context().on('requestfailed', resolve) - }) - - const expected = this.build.switch({ - windows: () => 'duck://settings/duckplayer', - apple: () => 'about:preferences/duckplayer' - }) - - const openSettings = this.page.locator('.open-settings') + const expected = 'duck://settings/duckplayer' + const openSettings = this.page.getByRole('link', { name: 'Open Settings' }) expect(await openSettings.getAttribute('href')).toEqual(expected) expect(await openSettings.getAttribute('target')).toEqual('_blank') - - // click to ensure a new tab opens - await openSettings.click() - - // ensure a new tab was opened (eg: that nothing in our JS stopped the regular click) - await newTab } async opensInYoutube () { @@ -258,7 +331,7 @@ export class DuckPlayerPage { resolve(f.url()) }) }) - await this.page.getByRole('link', { name: 'Watch on YouTube' }).click() + await this.page.getByRole('button', { name: 'Watch on YouTube' }).click() expect(await failure).toEqual('duck://player/openInYoutube?v=VIDEO_ID') }, apple: async () => { @@ -267,7 +340,7 @@ export class DuckPlayerPage { resolve(f.url()) }) }) - await this.page.getByRole('link', { name: 'Watch on YouTube' }).click() + await this.page.getByRole('button', { name: 'Watch on YouTube' }).click() expect(await nextNavigation).toEqual('https://www.youtube.com/watch?v=VIDEO_ID') } }) @@ -286,8 +359,23 @@ export class DuckPlayerPage { expect(await failure).toEqual(`duck://player/openInYoutube?v=${videoID}`) }, apple: async () => { + if (this.platform.name === 'ios') { + // todo: why does this not work on ios?? + await action() + return + } await action() await this.page.waitForURL(`https://www.youtube.com/watch?v=${videoID}`) + }, + android: async () => { + // const failure = new Promise(resolve => { + // this.page.context().on('requestfailed', f => { + // resolve(f.url()) + // }) + // }) + // todo: why does this not work on android? + await action() + // expect(await failure).toEqual(`duck://player/openInYoutube?v=${videoID}`) } }) } @@ -316,12 +404,11 @@ export class DuckPlayerPage { } async toggleAlwaysOpenSetting () { - await this.page.getByLabel('Always open YouTube videos in Duck Player').click() + await this.page.getByLabel('Always open YouTube videos here').click() } async settingsAreVisible () { - // ensure the settings container is visible, because 'always open' setting was off ^^^ - await expect(this.page.locator('.setting-container')).toBeVisible() + await expect(this.page.getByRole('button', { name: 'Watch on YouTube' })).toBeVisible() } async sentUpdatedSettings () { @@ -349,9 +436,8 @@ export class DuckPlayerPage { async storageClearedAfterReload () { await this.page.reload() const storaget = await this.page.evaluate(() => localStorage) - expect(storaget).toMatchObject({ - 'yt-player-other': 'baz' - }) + const keys = Object.keys(storaget) + expect(keys).toStrictEqual(['yt-player-other']) } /** @@ -362,6 +448,7 @@ export class DuckPlayerPage { get basePath () { return this.build.switch({ windows: () => '../../build/windows/pages/duckplayer', + android: () => '../../build/android/pages/duckplayer', apple: () => '../../Sources/ContentScopeScripts/dist/pages/duckplayer' }) } @@ -380,4 +467,42 @@ export class DuckPlayerPage { const expected = 'allow-popups allow-scripts allow-same-origin allow-popups-to-escape-sandbox' await expect(this.page.locator('iframe')).toHaveAttribute('sandbox', expected) } + + async openSettings () { + const { page } = this + await page.getByLabel('Open Settings').click() + } + + async didOpenMobileSettings () { + await this.build.switch({ + android: async () => { + await this.mocks.waitForCallCount({ count: 1, method: 'openSettings' }) + }, + apple: async () => { + await this.mocks.waitForCallCount({ count: 1, method: 'openSettings' }) + } + }) + } + + async didOpenSettings () { + await this.mocks.waitForCallCount({ count: 1, method: 'openSettings' }) + } + + async didOpenInfo () { + await this.mocks.waitForCallCount({ count: 1, method: 'openInfo' }) + } + + async didWatchOnYoutube () { + + } + + async watchOnYoutube () { + const { page } = this + await page.getByRole('button', { name: 'Watch on YouTube' }).click() + } + + async openInfo () { + const { page } = this + await page.getByRole('button', { name: 'Open Info' }).click() + } } diff --git a/packages/special-pages/tests/page-objects/mocks.js b/packages/special-pages/tests/page-objects/mocks.js index ce9d494f9..357b77fd9 100644 --- a/packages/special-pages/tests/page-objects/mocks.js +++ b/packages/special-pages/tests/page-objects/mocks.js @@ -1,4 +1,5 @@ import { + mockAndroidMessaging, mockWebkitMessaging, mockWindowsMessaging, readOutgoingMessages, @@ -49,6 +50,13 @@ export class Mocks { messagingContext: this.messagingContext, responses: this._defaultResponses }) + }, + android: async () => { + await this.page.addInitScript(mockAndroidMessaging, { + messagingContext: this.messagingContext, + responses: this._defaultResponses, + messageCallback: 'messageCallback' + }) } }) }