diff --git a/special-pages/pages/duckplayer/app/components/Button.module.css b/special-pages/pages/duckplayer/app/components/Button.module.css index 19cc263cdb..5d6845ee2a 100644 --- a/special-pages/pages/duckplayer/app/components/Button.module.css +++ b/special-pages/pages/duckplayer/app/components/Button.module.css @@ -15,7 +15,7 @@ text-decoration: none; [data-layout="mobile"] & { - background-color: #2f2f2f; + background-color: rgba(255, 255, 255, 0.12); } } diff --git a/special-pages/pages/duckplayer/app/components/Components.jsx b/special-pages/pages/duckplayer/app/components/Components.jsx index 4796508448..b866a91703 100644 --- a/special-pages/pages/duckplayer/app/components/Components.jsx +++ b/special-pages/pages/duckplayer/app/components/Components.jsx @@ -14,6 +14,7 @@ import { Settings } from '../settings.js'; import { EmbedSettings } from '../embed-settings.js'; import { SwitchBarDesktop } from './SwitchBarDesktop.jsx'; import { SwitchProvider } from '../providers/SwitchProvider.jsx'; +import { YouTubeError } from './YouTubeError'; export function Components() { const settings = new Settings({ @@ -81,6 +82,26 @@ export function Components() {
+ + + + + + + + +
+ + + + + + + + + +
+

inset=true (mobile)

@@ -90,7 +111,22 @@ export function Components() { +
+ + + + + + +
+ + + + + + +
diff --git a/special-pages/pages/duckplayer/app/components/Components.module.css b/special-pages/pages/duckplayer/app/components/Components.module.css index 9c0b4d88ef..7aa2c776f1 100644 --- a/special-pages/pages/duckplayer/app/components/Components.module.css +++ b/special-pages/pages/duckplayer/app/components/Components.module.css @@ -1,4 +1,5 @@ .main { + background-color: #000; color: white; max-width: 3840px; margin: 0 auto; diff --git a/special-pages/pages/duckplayer/app/components/DesktopApp.jsx b/special-pages/pages/duckplayer/app/components/DesktopApp.jsx index 3784e7f556..136a20b80a 100644 --- a/special-pages/pages/duckplayer/app/components/DesktopApp.jsx +++ b/special-pages/pages/duckplayer/app/components/DesktopApp.jsx @@ -3,9 +3,11 @@ import styles from './DesktopApp.module.css'; import { InfoBar, InfoBarContainer } from './InfoBar.jsx'; import { PlayerContainer } from './PlayerContainer.jsx'; import { Player, PlayerError } from './Player.jsx'; +import { YouTubeError } from './YouTubeError'; import { useSettings } from '../providers/SettingsProvider.jsx'; import { createAppFeaturesFrom } from '../features/app.js'; import { HideInFocusMode } from './FocusMode.jsx'; +import { useYouTubeError } from '../providers/YouTubeErrorProvider'; /** * @param {object} props @@ -14,10 +16,12 @@ import { HideInFocusMode } from './FocusMode.jsx'; export function DesktopApp({ embed }) { const settings = useSettings(); const features = createAppFeaturesFrom(settings); + const youtubeError = useYouTubeError(); + return ( <> {features.focusMode()} -
+
@@ -29,11 +33,16 @@ export function DesktopApp({ embed }) { * @param {import("../embed-settings.js").EmbedSettings|null} props.embed */ function DesktopLayout({ embed }) { + const youtubeError = useYouTubeError(); + const settings = useSettings(); + const showCustomError = youtubeError && settings.customError?.state === 'enabled'; + return (
{embed === null && } - {embed !== null && } + {embed !== null && showCustomError && } + {embed !== null && !showCustomError && } diff --git a/special-pages/pages/duckplayer/app/components/MobileApp.jsx b/special-pages/pages/duckplayer/app/components/MobileApp.jsx index d8ec9b7c47..fa79e1fd58 100644 --- a/special-pages/pages/duckplayer/app/components/MobileApp.jsx +++ b/special-pages/pages/duckplayer/app/components/MobileApp.jsx @@ -2,6 +2,7 @@ import { h, Fragment } from 'preact'; import cn from 'classnames'; import styles from './MobileApp.module.css'; import { Player, PlayerError } from './Player.jsx'; +import { YouTubeError } from './YouTubeError'; import { usePlatformName, useSettings } from '../providers/SettingsProvider.jsx'; import { SwitchBarMobile } from './SwitchBarMobile.jsx'; import { MobileWordmark } from './Wordmark.jsx'; @@ -11,6 +12,7 @@ import { MobileButtons } from './MobileButtons.jsx'; import { OrientationProvider } from '../providers/OrientationProvider.jsx'; import { FocusMode } from './FocusMode.jsx'; import { useTelemetry } from '../types.js'; +import { useYouTubeError } from '../providers/YouTubeErrorProvider'; const DISABLED_HEIGHT = 450; @@ -21,12 +23,16 @@ const DISABLED_HEIGHT = 450; export function MobileApp({ embed }) { const settings = useSettings(); const telemetry = useTelemetry(); + const youtubeError = useYouTubeError(); + const features = createAppFeaturesFrom(settings); return ( <> - {features.focusMode()} + {!youtubeError && features.focusMode()} { + if (youtubeError) return; + if (orientation === 'portrait') { return FocusMode.enable(); } @@ -51,12 +57,17 @@ export function MobileApp({ embed }) { */ function MobileLayout({ embed }) { const platformName = usePlatformName(); + const youtubeError = useYouTubeError(); + const settings = useSettings(); + const showCustomError = youtubeError && settings.customError?.state === 'enabled'; + return ( -
+
{embed === null && } - {embed !== null && } + {embed !== null && showCustomError && } + {embed !== null && !showCustomError && }
diff --git a/special-pages/pages/duckplayer/app/components/MobileApp.module.css b/special-pages/pages/duckplayer/app/components/MobileApp.module.css index 8355572d0b..0e75637b18 100644 --- a/special-pages/pages/duckplayer/app/components/MobileApp.module.css +++ b/special-pages/pages/duckplayer/app/components/MobileApp.module.css @@ -34,6 +34,8 @@ html[data-focus-mode="on"] .hideInFocus { --inner-radius: 12px; --logo-width: 157px; --inner-padding: 8px; + --mobile-buttons-padding: 8px; + position: relative; max-width: 100vh; margin: 0 auto; @@ -113,6 +115,16 @@ body:has([data-state="completed"] [aria-checked="true"]) .switch { height: 44px; } +.detachedControls { + grid-area: detached; + display: flex; + flex-flow: column; + gap: 8px; + padding: 8px; + background: #2f2f2f; + border-radius: 12px; +} + @media screen and (min-width: 425px) and (max-height: 600px) { .main { /* reset logo positioning */ @@ -222,3 +234,113 @@ body:has([data-state="completed"] [aria-checked="true"]) .switch { justify-content: end; } } + +/* Different layout for YouTube Errors on mobile */ +.main[data-youtube-error="true"] { + @media screen and (max-width: 599px) { + --bg-color: transparent; + --inner-padding: 4px; + + grid-template-areas: + 'logo' + 'gap3' + 'embed' + 'gap4' + 'switch' + 'buttons'; + grid-template-rows: + max-content + 16px + auto + 12px + max-content + max-content; + + & .embed { + background: #2f2f2f; + border-radius: var(--outer-radius); + padding: 4px; + } + + & .switch { + background: #2f2f2f; + padding: 8px 8px 0 8px; + height: 60px; + max-height: 60px; + border-top-left-radius: var(--outer-radius); + border-top-right-radius: var(--outer-radius); + + transition: all 0.3s; + } + + & .buttons { + background: #2f2f2f; + padding: 8px; + + transition: all 0.3s; + } + + &:has([data-state="completed"]) { + & .buttons { + border-radius: var(--outer-radius); + } + + & .switch { + background: transparent; + max-height: 0; + } + } + } + + /* Hide chrome on smaller screens */ + @media screen and (max-width: 599px) and (max-height: 599px) { + max-width: unset; + + grid-template-rows: + 0 + 0 + auto + 12px + 0 + max-content; + + & .logo, + & .switch { + display: none; + } + + & .buttons { + border-radius: var(--outer-radius); + } + } + + /* Show buttons on landscape */ + @media screen and (min-width: 600px) and (max-height: 450px) { + grid-template-areas: + 'embed' + 'buttons' + 'gap5'; + + grid-template-rows: + auto + max-content + 8px; + + & .buttons { + border-radius: var(--outer-radius); + display: block; + } + } + + /* Sticky buttons on very low heights */ + @media screen and (max-height: 320px) { + & .embed { + overflow-y: auto; + } + + & .buttons { + bottom: 0; + position: sticky; + } + } +} \ No newline at end of file diff --git a/special-pages/pages/duckplayer/app/components/Player.jsx b/special-pages/pages/duckplayer/app/components/Player.jsx index 1420dc43da..0815c7bb5f 100644 --- a/special-pages/pages/duckplayer/app/components/Player.jsx +++ b/special-pages/pages/duckplayer/app/components/Player.jsx @@ -102,6 +102,7 @@ function useIframeEffects(src) { features.clickCapture(), features.titleCapture(), features.mouseCapture(), + features.errorDetection(), ]; /** diff --git a/special-pages/pages/duckplayer/app/components/SwitchBarMobile.module.css b/special-pages/pages/duckplayer/app/components/SwitchBarMobile.module.css index 685122b294..7d37733e14 100644 --- a/special-pages/pages/duckplayer/app/components/SwitchBarMobile.module.css +++ b/special-pages/pages/duckplayer/app/components/SwitchBarMobile.module.css @@ -1,7 +1,7 @@ .switchBar { display: grid; border-radius: 8px; - background: #2f2f2f; + background: rgba(255, 255, 255, 0.12); padding-inline: 16px; height: 100%; line-height: 1.1; diff --git a/special-pages/pages/duckplayer/app/components/Wordmark-mobile.module.css b/special-pages/pages/duckplayer/app/components/Wordmark-mobile.module.css index c80fa824cd..dc50bd6787 100644 --- a/special-pages/pages/duckplayer/app/components/Wordmark-mobile.module.css +++ b/special-pages/pages/duckplayer/app/components/Wordmark-mobile.module.css @@ -11,6 +11,13 @@ .logo { height: 100px; } + + /* TODO: Can this be moved somewhere else? */ + [data-youtube-error="true"] { + & .logo { + height: 44px; + } + } } .logoSvg img { display: block; diff --git a/special-pages/pages/duckplayer/app/components/YouTubeError.jsx b/special-pages/pages/duckplayer/app/components/YouTubeError.jsx new file mode 100644 index 0000000000..6770667497 --- /dev/null +++ b/special-pages/pages/duckplayer/app/components/YouTubeError.jsx @@ -0,0 +1,83 @@ +import { h } from 'preact'; +import cn from 'classnames'; +import { Settings } from '../settings'; +import { useTypedTranslation } from '../types.js'; + +import styles from './YouTubeError.module.css'; + +/** + * @typedef {import('../../types/duckplayer').YouTubeError} YouTubeError + * @typedef {import('preact').ComponentChild} ComponentChild + */ + +/** + * @param {YouTubeError} kind + * @returns {{heading: ComponentChild, messages: ComponentChild[], variant: 'list'|'inline'|'paragraphs'}} + */ +function useErrorStrings(kind) { + const { t } = useTypedTranslation(); + + switch (kind) { + case 'sign-in-required': + return { + heading: t('blockedVideoErrorHeading'), + messages: [t('signInRequiredErrorMessage1'), t('signInRequiredErrorMessage2')], + variant: 'paragraphs', + }; + default: + return { + heading: t('blockedVideoErrorHeading'), + messages: [t('blockedVideoErrorMessage1'), t('blockedVideoErrorMessage2')], + variant: 'paragraphs', + }; + } +} + +/** + * @param {object} props + * @param {YouTubeError} props.kind + * @param {Settings['layout']} props.layout + */ +export function YouTubeError({ kind, layout }) { + const { heading, messages, variant } = useErrorStrings(kind); + const classes = cn(styles.error, { + [styles.desktop]: layout === 'desktop', + [styles.mobile]: layout === 'mobile', + }); + + return ( +
+
+ + +
+

{heading}

+ + {messages && variant === 'inline' && ( +

+ {messages.map((item) => ( + {item} + ))} +

+ )} + + {messages && variant === 'paragraphs' && ( +
+ {messages.map((item) => ( +

{item}

+ ))} +
+ )} + + {messages && variant === 'list' && ( +
    + {messages.map((item) => ( +
  • {item}
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/special-pages/pages/duckplayer/app/components/YouTubeError.module.css b/special-pages/pages/duckplayer/app/components/YouTubeError.module.css new file mode 100644 index 0000000000..c040a7cf1f --- /dev/null +++ b/special-pages/pages/duckplayer/app/components/YouTubeError.module.css @@ -0,0 +1,134 @@ +.error { + align-items: center; + background: rgba(0, 0, 0, 0.6); + display: grid; + height: 100%; + justify-items: center; +} + +.error.desktop { + height: var(--frame-height); + overflow: hidden; + position: relative; + z-index: 1; +} + +.error.mobile { + border-radius: var(--inner-radius); + height: 100%; + overflow: auto; + + /* Prevents automatic text resizing */ + text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + + @media screen and (min-width: 600px) and (min-height: 600px) { + aspect-ratio: 16 / 9; + } +} + +.desktop { + border-top-left-radius: var(--outer-radius); + border-top-right-radius: var(--outer-radius); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.container { + column-gap: 24px; + display: flex; + flex-flow: row; + margin: 0; + max-width: 680px; + padding: 0 40px; + row-gap: 4px; +} + +.mobile .container { + flex-flow: column; + padding: 0 24px; + + @media screen and (min-height: 320px) { + margin: 16px 0; + } + + @media screen and (min-width: 375px) and (min-height: 400px) { + margin: 36px 0; + } +} + +.content { + display: flex; + flex-direction: column; + gap: 4px; + margin: 16px 0; + + @media screen and (min-width: 600px) { + margin: 24px 0; + } +} + + +.icon { + align-self: center; + display: flex; + justify-content: center; + + &::before { + content: ' '; + display: block; + background: url('../img/warning-96.data.svg') no-repeat; + height: 48px; + width: 48px; + } + + @media screen and (max-width: 320px) { + display: none; + } + + @media screen and (min-width: 600px) and (min-height: 600px) { + justify-content: start; + + &::before { + background-image: url('../img/warning-128.data.svg'); + height: 96px; + width: 128px; + } + } +} + +.heading { + color: #fff; + font-size: 20px; + font-weight: 700; + line-height: calc(24 / 20); + margin: 0; +} + +.messages { + color: #ccc; + font-size: 16px; + line-height: calc(24 / 16); +} + +div.messages { + display: flex; + flex-direction: column; + gap: 24px; + + & p { + margin: 0; + } +} + +p.messages { + margin: 0; +} + +ul.messages { + li { + list-style: disc; + margin-left: 24px; + } +} + diff --git a/special-pages/pages/duckplayer/app/features/error-detection.js b/special-pages/pages/duckplayer/app/features/error-detection.js new file mode 100644 index 0000000000..6ce3ddbdd6 --- /dev/null +++ b/special-pages/pages/duckplayer/app/features/error-detection.js @@ -0,0 +1,144 @@ +import { YOUTUBE_ERROR_EVENT, YOUTUBE_ERRORS } from '../providers/YouTubeErrorProvider'; + +/** + * @typedef {import("./iframe").IframeFeature} IframeFeature + * @typedef {import('../../types/duckplayer').YouTubeError} YouTubeError + * @typedef {import('../../types/duckplayer').DuckPlayerPageSettings['customError']} CustomErrorOptions + */ + +/** + * Detects YouTube errors based on DOM queries + * + * @implements IframeFeature + */ +export class ErrorDetection { + /** @type {HTMLIFrameElement} */ + iframe; + + /** @type {CustomErrorOptions} */ + options; + + /** + * @param {CustomErrorOptions} options + */ + constructor(options) { + this.options = options; + } + + /** + * @param {HTMLIFrameElement} iframe + */ + iframeDidLoad(iframe) { + this.iframe = iframe; + + if (!this.options || !this.options.signInRequiredSelector) { + console.log('Missing Custom Error options'); + return null; + } + + const documentBody = iframe.contentWindow?.document?.body; + if (documentBody) { + // Check if iframe already contains error + if (this.checkForError(documentBody)) { + const error = this.getErrorType(); + window.dispatchEvent(new CustomEvent(YOUTUBE_ERROR_EVENT, { detail: { error } })); + + return null; + } + + // Create a MutationObserver instance + const observer = new MutationObserver(this.handleMutation.bind(this)); + + // Start observing the iframe's document for changes + observer.observe(documentBody, { + childList: true, + subtree: true, // Observe all descendants of the body + }); + + return () => { + observer.disconnect(); + }; + } + + return null; + } + + /** + * Mutation handler that checks new nodes for error states + * + * @type {MutationCallback} + */ + handleMutation(mutationsList) { + for (const mutation of mutationsList) { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach((node) => { + if (this.checkForError(node)) { + console.log('A node with an error has been added to the document:', node); + const error = this.getErrorType(); + + window.dispatchEvent(new CustomEvent(YOUTUBE_ERROR_EVENT, { detail: { error } })); + } + }); + } + } + } + + /** + * Attempts to detect the type of error in the YouTube embed iframe + * @returns {YouTubeError} + */ + getErrorType() { + const iframeWindow = /** @type {Window & { ytcfg: object }} */ (this.iframe.contentWindow); + let playerResponse; + + try { + playerResponse = JSON.parse(iframeWindow.ytcfg?.get('PLAYER_VARS')?.embedded_player_response); + } catch (e) { + console.log('Could not parse player response', e); + } + + if (typeof playerResponse === 'object') { + const { + previewPlayabilityStatus: { desktopLegacyAgeGateReason, status }, + } = playerResponse; + + // 1. Check for UNPLAYABLE status + if (status === 'UNPLAYABLE') { + // 1.1. Check for presence of desktopLegacyAgeGateReason + if (desktopLegacyAgeGateReason === 1) { + return YOUTUBE_ERRORS.ageRestricted; + } + + // 1.2. Fall back to embed not allowed error + return YOUTUBE_ERRORS.noEmbed; + } + + // 2. Check for sign-in support link + try { + if (this.options?.signInRequiredSelector && !!iframeWindow.document.querySelector(this.options.signInRequiredSelector)) { + return YOUTUBE_ERRORS.signInRequired; + } + } catch (e) { + console.log('Sign-in required query failed', e); + } + } + + // 3. Fall back to unknown error + return YOUTUBE_ERRORS.unknown; + } + + /** + * Analyses a node and its children to determine if it contains an error state + * + * @param {Node} [node] + */ + checkForError(node) { + if (node?.nodeType === Node.ELEMENT_NODE) { + const element = /** @type {HTMLElement} */ (node); + // Check if element has the error class or contains any children with that class + return element.classList.contains('ytp-error') || !!element.querySelector('ytp-error'); + } + + return false; + } +} diff --git a/special-pages/pages/duckplayer/app/features/iframe.js b/special-pages/pages/duckplayer/app/features/iframe.js index b252724b2a..c9c0fb0ef3 100644 --- a/special-pages/pages/duckplayer/app/features/iframe.js +++ b/special-pages/pages/duckplayer/app/features/iframe.js @@ -3,6 +3,7 @@ import { AutoFocus } from './autofocus.js'; import { ClickCapture } from './click-capture.js'; import { TitleCapture } from './title-capture.js'; import { MouseCapture } from './mouse-capture.js'; +import { ErrorDetection } from './error-detection.js'; /** * Represents an individual piece of functionality in the iframe. @@ -74,5 +75,11 @@ export function createIframeFeatures(settings) { mouseCapture: () => { return new MouseCapture(); }, + /** + * @return {IframeFeature} + */ + errorDetection: () => { + return new ErrorDetection(settings.customError); + }, }; } diff --git a/special-pages/pages/duckplayer/app/img/warning-128.data.svg b/special-pages/pages/duckplayer/app/img/warning-128.data.svg new file mode 100644 index 0000000000..b0392efdce --- /dev/null +++ b/special-pages/pages/duckplayer/app/img/warning-128.data.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/special-pages/pages/duckplayer/app/img/warning-96.data.svg b/special-pages/pages/duckplayer/app/img/warning-96.data.svg new file mode 100644 index 0000000000..af6bc6fbe9 --- /dev/null +++ b/special-pages/pages/duckplayer/app/img/warning-96.data.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/special-pages/pages/duckplayer/app/index.js b/special-pages/pages/duckplayer/app/index.js index da81d0f8b9..a0722feffc 100644 --- a/special-pages/pages/duckplayer/app/index.js +++ b/special-pages/pages/duckplayer/app/index.js @@ -14,6 +14,9 @@ import { Fallback } from '../../../shared/components/Fallback/Fallback.jsx'; import { Components } from './components/Components.jsx'; import { MobileApp } from './components/MobileApp.jsx'; import { DesktopApp } from './components/DesktopApp.jsx'; +import { YouTubeErrorProvider } from './providers/YouTubeErrorProvider'; + +/** @typedef {import('../types/duckplayer').YouTubeError} YouTubeError */ /** * @param {import("../src/index.js").DuckplayerPage} messaging @@ -55,7 +58,11 @@ export async function init(messaging, telemetry, baseEnvironment) { .withFeatureState('pip', init.settings.pip) .withFeatureState('autoplay', init.settings.autoplay) .withFeatureState('focusMode', init.settings.focusMode) - .withDisabledFocusMode(baseEnvironment.urlParams.get('focusMode')); + .withFeatureState('customError', init.settings.customError) + .withDisabledFocusMode(baseEnvironment.urlParams.get('focusMode')) + .withCustomError(baseEnvironment.urlParams.get('customError')); + + const initialYouTubeError = /** @type {YouTubeError} */ (baseEnvironment.urlParams.get('youtubeError')); console.log(settings); @@ -79,27 +86,29 @@ export async function init(messaging, telemetry, baseEnvironment) { - - {settings.layout === 'desktop' && ( - - - - )} - {settings.layout === 'mobile' && ( - - - - )} - - + + + {settings.layout === 'desktop' && ( + + + + )} + {settings.layout === 'mobile' && ( + + + + )} + + + diff --git a/special-pages/pages/duckplayer/app/providers/YouTubeErrorProvider.jsx b/special-pages/pages/duckplayer/app/providers/YouTubeErrorProvider.jsx new file mode 100644 index 0000000000..a91916685d --- /dev/null +++ b/special-pages/pages/duckplayer/app/providers/YouTubeErrorProvider.jsx @@ -0,0 +1,75 @@ +import { useContext, useState } from 'preact/hooks'; +import { h, createContext } from 'preact'; +import { useEffect } from 'preact/hooks'; +import { useMessaging } from '../types'; +import { usePlatformName } from './SettingsProvider'; +import { useSetFocusMode } from '../components/FocusMode'; + +export const YOUTUBE_ERROR_EVENT = 'ddg-duckplayer-youtube-error'; + +/** + * @typedef {import('../../types/duckplayer').YouTubeError} YouTubeError + */ + +/** @type {Record} */ +export const YOUTUBE_ERRORS = { + ageRestricted: 'age-restricted', + signInRequired: 'sign-in-required', + noEmbed: 'no-embed', + unknown: 'unknown', +}; + +/** @type {YouTubeError[]} */ +export const YOUTUBE_ERROR_IDS = Object.values(YOUTUBE_ERRORS); + +const YouTubeErrorContext = createContext({ + /** @type {YouTubeError|null} */ + error: null, +}); + +/** + * @param {object} props + * @param {YouTubeError|null} [props.initial=null] + * @param {import("preact").ComponentChild} props.children + */ +export function YouTubeErrorProvider({ initial = null, children }) { + // initial state + let initialError = null; + if (initial && YOUTUBE_ERROR_IDS.includes(initial)) { + initialError = initial; + } + const [error, setError] = useState(initialError); + + const messaging = useMessaging(); + const platformName = usePlatformName(); + const setFocusMode = useSetFocusMode(); + + // listen for updates + useEffect(() => { + /** @type {(event: CustomEvent) => void} */ + const errorEventHandler = (event) => { + const eventError = event.detail?.error; + if (YOUTUBE_ERROR_IDS.includes(eventError) || eventError === null) { + if (eventError && eventError !== error) { + setFocusMode('paused'); + if (platformName === 'macos' || platformName === 'ios') { + messaging.reportYouTubeError({ error: eventError }); + } + } else { + setFocusMode('enabled'); + } + setError(eventError); + } + }; + + window.addEventListener(YOUTUBE_ERROR_EVENT, errorEventHandler); + + return () => window.removeEventListener(YOUTUBE_ERROR_EVENT, errorEventHandler); + }, []); + + return {children}; +} + +export function useYouTubeError() { + return useContext(YouTubeErrorContext).error; +} diff --git a/special-pages/pages/duckplayer/app/settings.js b/special-pages/pages/duckplayer/app/settings.js index f0f52adc70..46f88dc786 100644 --- a/special-pages/pages/duckplayer/app/settings.js +++ b/special-pages/pages/duckplayer/app/settings.js @@ -1,3 +1,5 @@ +const DEFAULT_SIGN_IN_REQURED_HREF = '[href*="//support.google.com/youtube/answer/3037019"]'; + export class Settings { /** * @param {object} params @@ -5,17 +7,20 @@ export class Settings { * @param {{state: 'enabled' | 'disabled'}} [params.pip] * @param {{state: 'enabled' | 'disabled'}} [params.autoplay] * @param {{state: 'enabled' | 'disabled'}} [params.focusMode] + * @param {import("../types/duckplayer.js").InitialSetupResponse['settings']['customError']} [params.customError] */ constructor({ platform = { name: 'macos' }, pip = { state: 'disabled' }, autoplay = { state: 'enabled' }, focusMode = { state: 'enabled' }, + customError = { state: 'disabled', signInRequiredSelector: '' }, }) { this.platform = platform; this.pip = pip; this.autoplay = autoplay; this.focusMode = focusMode; + this.customError = customError; } /** @@ -26,7 +31,7 @@ export class Settings { withFeatureState(named, settings) { if (!settings) return this; /** @type {(keyof import("../types/duckplayer.js").DuckPlayerPageSettings)[]} */ - const valid = ['pip', 'autoplay', 'focusMode']; + const valid = ['pip', 'autoplay', 'focusMode', 'customError']; if (!valid.includes(named)) { console.warn(`Excluding invalid feature key ${named}`); return this; @@ -68,6 +73,31 @@ export class Settings { return this; } + /** + * @param {string|null|undefined} newState + * @return {Settings} + */ + withCustomError(newState) { + if (newState === 'disabled') { + return new Settings({ + ...this, + customError: { state: 'disabled' }, + }); + } + + if (newState === 'enabled') { + return new Settings({ + ...this, + customError: { + state: 'enabled', + signOnRequiredSelector: DEFAULT_SIGN_IN_REQURED_HREF, + }, + }); + } + + return this; + } + /** * @return {string} */ diff --git a/special-pages/pages/duckplayer/integration-tests/duck-player.js b/special-pages/pages/duckplayer/integration-tests/duck-player.js index 4ea48c3ee6..7cd569e41f 100644 --- a/special-pages/pages/duckplayer/integration-tests/duck-player.js +++ b/special-pages/pages/duckplayer/integration-tests/duck-player.js @@ -22,6 +22,26 @@ const html = {
+`, + signInRequired: `${MOCK_VIDEO_TITLE} + + + `, }; @@ -156,6 +176,14 @@ export class DuckPlayerPage { }); } + if (urlParams.get('videoID') === 'SIGN_IN_REQUIRED') { + return request.fulfill({ + status: 200, + body: html.signInRequired, + contentType: 'text/html', + }); + } + const mp4VideoPlaceholderAsDataURI = 'data:video/mp4;base64,AAAAHGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAwFtZGF0AAACogYF//+b3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE1MiByMjg1NCBlMjA5YTFjIC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAxNyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzowMTMzIHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5nZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEgZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02MyBsb29rYWhlYWRfdGhyZWFkcz0yIHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9MCBibHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVzPTMgYl9weXJhbWlkPTIgYl9hZGFwdD1xLTIgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtleWludD0yNTAga2V5aW50X21pbj0yNSBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmM9bG9va2FoZWFkIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCB2YnY9MCBjbG9zZWRfZ29wPTAgY3V0X3Rocm91Z2g9MCAnbm8tZGlndHMuanBnLTFgcC1mbHWinS3SlB8AP0AAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABSAAAAAAAAAAAAAAAAAAABBZHJ0AAAAAAAAAA=='; return request.fulfill({ @@ -214,6 +242,16 @@ export class DuckPlayerPage { await this.openPage(params); } + /** + * @param {import('../types/duckplayer.ts').YouTubeError} [youtubeError] + * @param {string} [videoID] + * @returns {Promise} + */ + async openWithYouTubeError(youtubeError = 'unknown', videoID = 'e90eWYPNtJ8') { + const params = new URLSearchParams({ youtubeError, videoID, customError: 'enabled', focusMode: 'disabled' }); + await this.openPage(params); + } + async openWithException() { const params = new URLSearchParams({ willThrow: String(true) }); await this.openPage(params); @@ -306,8 +344,12 @@ export class DuckPlayerPage { await expect(this.page.locator('iframe')).toHaveAttribute('src', expected); } - async hasShownErrorMessage() { - await expect(this.page.getByText('ERROR: Invalid video id')).toBeVisible(); + /** + * + * @param {string} text + */ + async hasShownErrorMessage(text = 'ERROR: Invalid video id') { + await expect(this.page.getByText(text)).toBeVisible(); } async hasNotAddedIframe() { @@ -396,6 +438,40 @@ export class DuckPlayerPage { }); } + async opensDuckPlayerYouTubeLinkFromError({ videoID = 'UNSUPPORTED' }) { + const action = () => this.page.getByRole('button', { name: 'Watch on YouTube' }).click(); + await this.build.switch({ + windows: async () => { + const failure = new Promise((resolve) => { + this.page.context().on('requestfailed', (f) => { + resolve(f.url()); + }); + }); + await action(); + 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}`) + }, + }); + } + /** * @return {Promise} */ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js index 41485178df..1573a015dc 100644 --- a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js +++ b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js @@ -26,6 +26,18 @@ test.describe('screenshots @screenshots', () => { await duckplayer.hasShownErrorMessage(); await expect(page).toHaveScreenshot('error-layout.png', { maxDiffPixels: 20 }); }); + test('youtube generic error', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('unknown'); + await duckplayer.hasShownErrorMessage('YouTube won’t let Duck Player load this video'); + await expect(page).toHaveScreenshot('youtube-error-unknown.png', { maxDiffPixels: 20 }); + }); + test('youtube sign-in error', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('sign-in-required'); + await duckplayer.hasShownErrorMessage('YouTube won’t let Duck Player load this video'); + await expect(page).toHaveScreenshot('youtube-error-sign-in-required.png', { maxDiffPixels: 20 }); + }); test('tooltip shown on hover', async ({ page }, workerInfo) => { test.skip(isMobile(workerInfo)); const duckplayer = DuckPlayerPage.create(page, workerInfo); diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-android-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-android-darwin.png new file mode 100644 index 0000000000..2c56c28fc8 Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-android-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-android-landscape-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-android-landscape-darwin.png new file mode 100644 index 0000000000..d659b2d5c2 Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-android-landscape-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-ios-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-ios-darwin.png new file mode 100644 index 0000000000..556c4c82f4 Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-ios-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-macos-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-macos-darwin.png new file mode 100644 index 0000000000..a574576e05 Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-macos-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-windows-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-windows-darwin.png new file mode 100644 index 0000000000..7677fb3260 Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-windows-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-android-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-android-darwin.png new file mode 100644 index 0000000000..6f88330fbf Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-android-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-android-landscape-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-android-landscape-darwin.png new file mode 100644 index 0000000000..038756cacf Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-android-landscape-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-ios-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-ios-darwin.png new file mode 100644 index 0000000000..9ba77fa6f9 Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-ios-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-macos-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-macos-darwin.png new file mode 100644 index 0000000000..7ae4e54ee1 Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-macos-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-windows-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-windows-darwin.png new file mode 100644 index 0000000000..0a93bd3710 Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-windows-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer.spec.js b/special-pages/pages/duckplayer/integration-tests/duckplayer.spec.js index d2aa8456dc..d7b8409d8d 100644 --- a/special-pages/pages/duckplayer/integration-tests/duckplayer.spec.js +++ b/special-pages/pages/duckplayer/integration-tests/duckplayer.spec.js @@ -96,6 +96,53 @@ test.describe('duckplayer iframe', () => { }); }); +test.describe('duckplayer custom error', () => { + test('shows custom error screen for videos that require sign-in', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('sign-in-required', 'e90eWYPNtJ8'); + await duckplayer.hasShownErrorMessage('YouTube won’t let Duck Player load this video'); + await duckplayer.hasShownErrorMessage('If you’re using a VPN, try turning it off'); + }); + test('supports "watch on youtube" for videos that require sign-in', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('sign-in-required', 'e90eWYPNtJ8'); + await duckplayer.opensDuckPlayerYouTubeLinkFromError({ videoID: 'e90eWYPNtJ8' }); + }); + test('shows custom error screen for videos that are age-restricted', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('age-restricted', 'e90eWYPNtJ8'); + await duckplayer.hasShownErrorMessage('YouTube won’t let Duck Player load this video'); + await duckplayer.hasShownErrorMessage('YouTube doesn’t allow this video to be viewed'); + }); + test('supports "watch on youtube" for videos that are age-restricted', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('age-restricted', 'e90eWYPNtJ8'); + await duckplayer.opensDuckPlayerYouTubeLinkFromError({ videoID: 'e90eWYPNtJ8' }); + }); + test('shows custom error screen for videos that can’t be embedded', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('no-embed', 'e90eWYPNtJ8'); + await duckplayer.hasShownErrorMessage('YouTube won’t let Duck Player load this video'); + await duckplayer.hasShownErrorMessage('YouTube doesn’t allow this video to be viewed'); + }); + test('supports "watch on youtube" for videos that can’t be embedded', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('no-embed', 'e90eWYPNtJ8'); + await duckplayer.opensDuckPlayerYouTubeLinkFromError({ videoID: 'e90eWYPNtJ8' }); + }); + test('shows custom error screen for videos with unknown errors', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('unknown', 'e90eWYPNtJ8'); + await duckplayer.hasShownErrorMessage('YouTube won’t let Duck Player load this video'); + await duckplayer.hasShownErrorMessage('YouTube doesn’t allow this video to be viewed'); + }); + test('supports "watch on youtube" for videos with unknown errors', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('unknown', 'e90eWYPNtJ8'); + await duckplayer.opensDuckPlayerYouTubeLinkFromError({ videoID: 'e90eWYPNtJ8' }); + }); +}); + test.describe('duckplayer toolbar', () => { test('hides toolbar based on user activity', async ({ page }, workerInfo) => { test.skip(isMobile(workerInfo)); diff --git a/special-pages/pages/duckplayer/messages/initialSetup.response.json b/special-pages/pages/duckplayer/messages/initialSetup.response.json index 75a99ca5cd..c222d0c053 100644 --- a/special-pages/pages/duckplayer/messages/initialSetup.response.json +++ b/special-pages/pages/duckplayer/messages/initialSetup.response.json @@ -38,6 +38,21 @@ "enum": ["enabled", "disabled"] } } + }, + "customError": { + "type": "object", + "description": "Configures a custom error message for YouTube errors", + "required": ["state", "signInRequiredSelector"], + "properties": { + "state": { + "type": "string", + "enum": ["enabled", "disabled"] + }, + "signInRequiredSelector": { + "description": "A selector that, when not empty, indicates a sign-in required error", + "type": "string" + } + } } } }, diff --git a/special-pages/pages/duckplayer/messages/reportYouTubeError.notify.json b/special-pages/pages/duckplayer/messages/reportYouTubeError.notify.json new file mode 100644 index 0000000000..f7b5b4c4b7 --- /dev/null +++ b/special-pages/pages/duckplayer/messages/reportYouTubeError.notify.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["error"], + "properties": { + "error": { "$ref": "youtubeError.shared.json"} + } +} diff --git a/special-pages/pages/duckplayer/messages/youtubeError.shared.json b/special-pages/pages/duckplayer/messages/youtubeError.shared.json new file mode 100644 index 0000000000..4616843e4b --- /dev/null +++ b/special-pages/pages/duckplayer/messages/youtubeError.shared.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "YouTubeError", + "type": "string", + "enum": ["age-restricted", "sign-in-required", "no-embed", "unknown"] +} diff --git a/special-pages/pages/duckplayer/public/locales/en/duckplayer.json b/special-pages/pages/duckplayer/public/locales/en/duckplayer.json index c2b5683b9f..fe94917c40 100644 --- a/special-pages/pages/duckplayer/public/locales/en/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/en/duckplayer.json @@ -33,6 +33,26 @@ "title": "ERROR: Invalid video id", "note": "Shown when the page URL doesn't match a known video ID. Note for translators: The 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." }, + "blockedVideoErrorHeading": { + "title": "YouTube won’t let Duck Player load this video", + "note": "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1": { + "title": "YouTube doesn’t allow this video to be viewed outside of YouTube.", + "note": "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2": { + "title": "You can still watch this video on YouTube, but without the added privacy of Duck Player.", + "note": "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorMessage1": { + "title": "YouTube is blocking this video from loading. If you’re using a VPN, try turning it off and reloading this page.", + "note": "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2": { + "title": "If this doesn’t work, you can still watch this video on YouTube, but without the added privacy of Duck Player.", + "note": "More troubleshooting tips for this specific error" + }, "tooltipInfo": { "title": "Duck Player provides a clean viewing experience without personalized ads and prevents viewing activity from influencing your YouTube recommendations." } diff --git a/special-pages/pages/duckplayer/src/index.js b/special-pages/pages/duckplayer/src/index.js index a9c40ae397..c677b427ea 100644 --- a/special-pages/pages/duckplayer/src/index.js +++ b/special-pages/pages/duckplayer/src/index.js @@ -91,6 +91,14 @@ export class DuckplayerPage { return this.messaging.subscribe('onUserValuesChanged', cb); } + /** + * This will be sent if the application fails to load. + * @param {{error: import('../types/duckplayer.ts').YouTubeError}} params + */ + reportYouTubeError(params) { + this.messaging.notify('reportYouTubeError', params); + } + /** * This will be sent if the application has loaded, but a client-side error * has occurred that cannot be recovered from diff --git a/special-pages/pages/duckplayer/types/duckplayer.ts b/special-pages/pages/duckplayer/types/duckplayer.ts index bb0d047c85..765db81883 100644 --- a/special-pages/pages/duckplayer/types/duckplayer.ts +++ b/special-pages/pages/duckplayer/types/duckplayer.ts @@ -6,6 +6,7 @@ * @module Duckplayer Messages */ +export type YouTubeError = "age-restricted" | "sign-in-required" | "no-embed" | "unknown"; export type PrivatePlayerMode = | { enabled: unknown; @@ -26,6 +27,7 @@ export interface DuckplayerMessages { | OpenSettingsNotification | ReportInitExceptionNotification | ReportPageExceptionNotification + | ReportYouTubeErrorNotification | TelemetryEventNotification; requests: GetUserValuesRequest | InitialSetupRequest | SetUserValuesRequest; subscriptions: OnUserValuesChangedSubscription; @@ -62,6 +64,16 @@ export interface ReportPageExceptionNotification { export interface ReportPageExceptionNotify { message: string; } +/** + * Generated from @see "../messages/reportYouTubeError.notify.json" + */ +export interface ReportYouTubeErrorNotification { + method: "reportYouTubeError"; + params: ReportYouTubeErrorNotify; +} +export interface ReportYouTubeErrorNotify { + error: YouTubeError; +} /** * Generated from @see "../messages/telemetryEvent.notify.json" */ @@ -117,6 +129,16 @@ export interface DuckPlayerPageSettings { focusMode?: { state: "enabled" | "disabled"; }; + /** + * Configures a custom error message for YouTube errors + */ + customError?: { + state: "enabled" | "disabled"; + /** + * A selector that, when not empty, indicates a sign-in required error + */ + signInRequiredSelector: string; + }; } /** * Generated from @see "../messages/setUserValues.request.json"