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 = {