diff --git a/src/components/CustomVideoPlayer/CustomVideoPlayerProvider.tsx b/src/components/CustomVideoPlayer/CustomVideoPlayerProvider.tsx index fc5a7626..98a11c97 100644 --- a/src/components/CustomVideoPlayer/CustomVideoPlayerProvider.tsx +++ b/src/components/CustomVideoPlayer/CustomVideoPlayerProvider.tsx @@ -28,9 +28,11 @@ type CustomVideoPlayerContext = { videoContainerRef: React.RefObject; onStateChange: (event: YouTubeEvent) => void; onPlayerReady: (event: YouTubeEvent) => void; + onPlayerError: (event: YouTubeEvent) => void; toggleVideoPlayback: () => void; manualPlayerState: number; playerState: number; + playerErrorCode: number | null; playbackSpeed: number; changeVideoPlaybackSpeed: (speed: number) => void; videoDuration: number; @@ -64,6 +66,7 @@ export function CustomVideoPlayerProvider({ children }: PropsWithChildren) { YOUTUBE_PLAYER_STATES.BUFFERING, ); const [manualPlayerState, setManualPlayerState] = useState(playerState); + const [playerErrorCode, setPlayerErrorCode] = useState(null); const [playbackSpeed, setPlaybackSpeed] = useState(1); const [videoProgress, setVideoProgress] = useState(0); @@ -200,6 +203,7 @@ export function CustomVideoPlayerProvider({ children }: PropsWithChildren) { // Set up the onApiChange listener when player is ready const onPlayerReady = useCallback( (event: YouTubeEvent) => { + setPlayerErrorCode(null); event.target.addEventListener("onApiChange", () => handleApiChange(event.target), ); @@ -207,6 +211,16 @@ export function CustomVideoPlayerProvider({ children }: PropsWithChildren) { [handleApiChange], ); + const onPlayerError = useCallback((event: YouTubeEvent) => { + const errorCode = Number(event.data); + + setPlayerErrorCode(Number.isNaN(errorCode) ? -1 : errorCode); + setManualPlayerState(YOUTUBE_PLAYER_STATES.PAUSED); + console.warn("YouTube player error", { + code: event.data, + }); + }, []); + const setCaptionTrack = useCallback((track: CaptionTrack) => { if (!playerRef?.current) return; const player = playerRef.current.internalPlayer; @@ -357,6 +371,8 @@ export function CustomVideoPlayerProvider({ children }: PropsWithChildren) { } if (event.data === YOUTUBE_PLAYER_STATES.PLAYING) { + setPlayerErrorCode(null); + // Load captions module when video starts playing (required for captions API) if (!hasLoadedCaptionsModuleRef.current) { hasLoadedCaptionsModuleRef.current = true; @@ -382,9 +398,11 @@ export function CustomVideoPlayerProvider({ children }: PropsWithChildren) { videoContainerRef, onStateChange, onPlayerReady, + onPlayerError, toggleVideoPlayback, manualPlayerState, playerState, + playerErrorCode, playbackSpeed, changeVideoPlaybackSpeed, videoDuration, diff --git a/src/components/CustomVideoPlayer/YouTubePlayerErrorOverlay.tsx b/src/components/CustomVideoPlayer/YouTubePlayerErrorOverlay.tsx new file mode 100644 index 00000000..d968e526 --- /dev/null +++ b/src/components/CustomVideoPlayer/YouTubePlayerErrorOverlay.tsx @@ -0,0 +1,49 @@ +import clsx from "clsx"; +import { ExternalLinkIcon } from "lucide-react"; + +type YouTubePlayerErrorOverlayProps = { + errorMessage: string; + isInactive: boolean; + onWatchOnYouTube: () => void; + orientation: "vertical" | "horizontal"; + videoID: string; +}; + +export function YouTubePlayerErrorOverlay({ + errorMessage, + isInactive, + onWatchOnYouTube, + orientation, + videoID, +}: YouTubePlayerErrorOverlayProps) { + return ( +
+
+ +
+ +
+ ); +} diff --git a/src/components/CustomVideoPlayer/constants.ts b/src/components/CustomVideoPlayer/constants.ts index b9298e0d..41db982e 100644 --- a/src/components/CustomVideoPlayer/constants.ts +++ b/src/components/CustomVideoPlayer/constants.ts @@ -53,3 +53,36 @@ export const YOUTUBE_CAPTION_SIZES = [ { label: "Larger", value: 2 }, { label: "Largest", value: 3 }, ]; + +export const YOUTUBE_PLAYER_ERROR_CODES = { + INVALID_PARAMETER: 2, + HTML5_PLAYER: 5, + NOT_FOUND_OR_PRIVATE: 100, + EMBED_NOT_ALLOWED: 101, + EMBED_NOT_ALLOWED_DISGUISED: 150, + MISSING_CLIENT_IDENTITY: 153, +} as const; + +export const YOUTUBE_PLAYER_DEFAULT_ERROR_MESSAGE = + "This YouTube embed could not be played."; + +export type YouTubePlayerErrorCode = + (typeof YOUTUBE_PLAYER_ERROR_CODES)[keyof typeof YOUTUBE_PLAYER_ERROR_CODES]; + +export const YOUTUBE_PLAYER_ERROR_MESSAGES: Record< + YouTubePlayerErrorCode, + string +> = { + [YOUTUBE_PLAYER_ERROR_CODES.INVALID_PARAMETER]: + "The YouTube video ID is invalid.", + [YOUTUBE_PLAYER_ERROR_CODES.HTML5_PLAYER]: + "YouTube could not play this video in the embedded player.", + [YOUTUBE_PLAYER_ERROR_CODES.NOT_FOUND_OR_PRIVATE]: + "This YouTube video was removed, is private, or could not be found.", + [YOUTUBE_PLAYER_ERROR_CODES.EMBED_NOT_ALLOWED]: + "This YouTube video cannot be played in embedded players.", + [YOUTUBE_PLAYER_ERROR_CODES.EMBED_NOT_ALLOWED_DISGUISED]: + "This YouTube video cannot be played in embedded players.", + [YOUTUBE_PLAYER_ERROR_CODES.MISSING_CLIENT_IDENTITY]: + "YouTube could not identify this embedded player request.", +}; diff --git a/src/components/CustomVideoPlayer/index.tsx b/src/components/CustomVideoPlayer/index.tsx index 6efa8503..1eb6f7f9 100644 --- a/src/components/CustomVideoPlayer/index.tsx +++ b/src/components/CustomVideoPlayer/index.tsx @@ -42,6 +42,8 @@ import { } from "./CustomVideoPlayerProvider"; import { YOUTUBE_CAPTION_SIZES, + YOUTUBE_PLAYER_DEFAULT_ERROR_MESSAGE, + YOUTUBE_PLAYER_ERROR_MESSAGES, YOUTUBE_PLAYBACK_SPEEDS, YOUTUBE_PLAYER_STATES, } from "./constants"; @@ -51,6 +53,7 @@ import { useRef, useEffect } from "react"; import { articleSelectedElementAtom } from "~/lib/hooks/useArticleNavigation"; import { useSaveProgress } from "~/lib/hooks/useSaveProgress"; import { useFeedItemValue } from "~/lib/data/store"; +import { YouTubePlayerErrorOverlay } from "./YouTubePlayerErrorOverlay"; interface IResponsiveVideoProps { videoID?: string; @@ -66,8 +69,10 @@ function CustomVideoPlayerContent(props: IResponsiveVideoProps) { playerRef, onStateChange, onPlayerReady, + onPlayerError, toggleVideoPlayback, manualPlayerState, + playerErrorCode, playbackSpeed, changeVideoPlaybackSpeed, videoDuration, @@ -155,6 +160,23 @@ function CustomVideoPlayerContent(props: IResponsiveVideoProps) { const [hasInlineShortcutsVisible] = useFlagState("INLINE_SHORTCUTS"); const player = playerRef?.current; + const originalVideoUrl = + savedFeedItem?.url ?? + (props.videoID + ? `https://www.youtube.com/watch?v=${props.videoID}` + : undefined); + const playerErrorMessage = + playerErrorCode === null + ? null + : (YOUTUBE_PLAYER_ERROR_MESSAGES[ + playerErrorCode as keyof typeof YOUTUBE_PLAYER_ERROR_MESSAGES + ] ?? YOUTUBE_PLAYER_DEFAULT_ERROR_MESSAGE); + const hasYouTubePlayerError = playerErrorMessage !== null; + const openOriginalVideoUrl = () => { + if (!originalVideoUrl) return; + + window.open(originalVideoUrl, "_blank", "noopener noreferrer"); + }; const shouldShowVideoTimestamps = videoType === "video"; const shouldShowLiveTimestamps = @@ -182,41 +204,45 @@ function CustomVideoPlayerContent(props: IResponsiveVideoProps) { }} onStateChange={onStateChange} onReady={onPlayerReady} + onError={onPlayerError} loading="eager" />
-
-
- -
- -
+ +
+ )}
+ {hasYouTubePlayerError && ( + + )} )} diff --git a/tests/e2e/fixtures/seed-db.ts b/tests/e2e/fixtures/seed-db.ts index eaebd37c..bbd4fa4a 100644 --- a/tests/e2e/fixtures/seed-db.ts +++ b/tests/e2e/fixtures/seed-db.ts @@ -158,6 +158,111 @@ export async function seedArticleData( return { feedItemId, email, password }; } +/** + * Creates a user via the Better Auth sign-up API, then seeds a YouTube feed + * and video item directly in the DB. + */ +export async function seedYouTubeVideoData( + tursoPort: number, + appPort: number, +): Promise<{ + feedItemId: string; + videoId: string; + originalUrl: string; + email: string; + password: string; +}> { + const testId = uniqueId(); + const email = `test-${testId}@example.com`; + const password = "testpassword123"; + + const res = await fetch( + `http://localhost:${appPort}/api/auth/sign-up/email`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: `http://localhost:${appPort}`, + }, + body: JSON.stringify({ name: "Test User", email, password }), + }, + ); + + if (!res.ok) { + throw new Error(`Sign-up failed: ${res.status} ${await res.text()}`); + } + + const { db, client } = getDb(tursoPort); + + const testUser = await db + .select() + .from(schema.user) + .where(eq(schema.user.email, email)) + .get(); + if (!testUser) throw new Error("No user found after sign-up"); + + const now = new Date(); + const farFuture = new Date(Date.now() + 1000 * 60 * 60 * 24 * 365); + + await db.insert(schema.views).values({ + userId: testUser.id, + name: "All", + daysWindow: 0, + readStatus: 0, + orientation: "horizontal", + contentType: "all", + layout: "list", + placement: 0, + createdAt: now, + updatedAt: now, + }); + + const [testFeed] = await db + .insert(schema.feeds) + .values({ + userId: testUser.id, + name: "Test YouTube Feed", + url: `https://www.youtube.com/feeds/videos.xml?channel_id=UC${testId.padEnd(22, "0")}`, + imageUrl: "", + platform: "youtube", + openLocation: "serial", + createdAt: now, + updatedAt: now, + lastFetchedAt: now, + nextFetchAt: farFuture, + }) + .returning(); + if (!testFeed) throw new Error("Feed insert returned no rows"); + + const videoId = "dQw4w9WgXcQ"; + const originalUrl = `https://www.youtube.com/watch?v=${videoId}`; + const feedItemId = `youtube-${testId}`; + + await db.insert(schema.feedItems).values({ + id: feedItemId, + feedId: testFeed.id, + contentId: videoId, + title: "Test YouTube Video", + author: "Test Channel", + url: originalUrl, + thumbnail: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`, + content: "", + contentSnippet: "Test YouTube video", + isWatched: false, + isWatchLater: false, + progress: 0, + duration: 0, + orientation: "horizontal", + postedAt: now, + createdAt: now, + updatedAt: now, + }); + + client.close(); + + return { feedItemId, videoId, originalUrl, email, password }; +} + /** * Creates a user via the Better Auth sign-up API, then seeds a website feed * and multiple articles with HTML content directly in the DB. diff --git a/tests/e2e/self-hosted/custom-video-player.spec.ts b/tests/e2e/self-hosted/custom-video-player.spec.ts new file mode 100644 index 00000000..911c2b38 --- /dev/null +++ b/tests/e2e/self-hosted/custom-video-player.spec.ts @@ -0,0 +1,137 @@ +import { expect, test } from "@playwright/test"; +import { signIn } from "../fixtures/auth"; +import { + SELF_HOSTED_APP_PORT, + SELF_HOSTED_TURSO_PORT, +} from "../fixtures/ports"; +import { cleanupUser, seedYouTubeVideoData } from "../fixtures/seed-db"; +import type { Page } from "@playwright/test"; + +async function mockYouTubePlayerError(page: Page, errorCode: number) { + await page.route(/\/\/www\.youtube\.com\/iframe_api/, async (route) => { + await route.fulfill({ + contentType: "application/javascript", + body: ` + (() => { + class MockYouTubePlayer { + constructor(element, options) { + this.element = typeof element === "string" ? document.getElementById(element) : element; + this.options = options; + this.listeners = {}; + this.iframe = document.createElement("iframe"); + this.iframe.src = "https://www.youtube-nocookie.com/embed/" + (options.videoId || ""); + this.iframe.title = "YouTube video player"; + this.element.appendChild(this.iframe); + + setTimeout(() => { + options.events.onReady({ target: this }); + setTimeout(() => { + options.events.onError({ data: ${errorCode}, target: this }); + }, 0); + }, 0); + } + + addEventListener(eventName, listener) { + this.listeners[eventName] = listener; + } + + removeEventListener(eventName) { + delete this.listeners[eventName]; + } + + cueVideoById() {} + loadVideoById() {} + cueVideoByUrl() {} + loadVideoByUrl() {} + playVideo() {} + pauseVideo() {} + stopVideo() {} + getVideoLoadedFraction() { return 0; } + cuePlaylist() {} + loadPlaylist() {} + nextVideo() {} + previousVideo() {} + playVideoAt() {} + setShuffle() {} + setLoop() {} + getPlaylist() { return []; } + getPlaylistIndex() { return 0; } + setOption() {} + mute() {} + unMute() {} + isMuted() { return false; } + setVolume() {} + getVolume() { return 100; } + seekTo() {} + getPlayerState() { return -1; } + getPlaybackRate() { return 1; } + setPlaybackRate() {} + getAvailablePlaybackRates() { return [1]; } + getPlaybackQuality() { return "default"; } + setPlaybackQuality() {} + getAvailableQualityLevels() { return []; } + getCurrentTime() { return 0; } + getDuration() { return 0; } + getVideoUrl() { return ""; } + getVideoEmbedCode() { return ""; } + getOptions() { return []; } + getOption() { return null; } + destroy() { this.iframe.remove(); } + setSize() {} + getIframe() { return this.iframe; } + loadModule() {} + } + + window.YT = { + Player: MockYouTubePlayer, + PlayerState: { + UNSTARTED: -1, + ENDED: 0, + PLAYING: 1, + PAUSED: 2, + BUFFERING: 3, + CUED: 5, + }, + }; + window.onYouTubeIframeAPIReady(); + })(); + `, + }); + }); +} + +test.describe("custom video player", () => { + test.use({ viewport: { width: 1920, height: 1080 } }); + + let testEmail: string; + + test.afterEach(async () => { + if (testEmail) { + await cleanupUser(SELF_HOSTED_TURSO_PORT, testEmail); + } + }); + + test("shows a Watch on YouTube CTA when the embedded player errors", async ({ + page, + }) => { + const { email, password, feedItemId, originalUrl } = + await seedYouTubeVideoData(SELF_HOSTED_TURSO_PORT, SELF_HOSTED_APP_PORT); + testEmail = email; + + await mockYouTubePlayerError(page, 150); + await signIn({ page, email, password }); + await page.goto(`/watch/${feedItemId}`); + + const watchOnYouTubeButton = page.getByRole("button", { + name: "This YouTube video cannot be played in embedded players.", + }); + await expect(watchOnYouTubeButton).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("Watch on YouTube")).toBeVisible(); + + const popupPromise = page.waitForEvent("popup"); + await watchOnYouTubeButton.click(); + const popup = await popupPromise; + + await expect(popup).toHaveURL(originalUrl); + }); +});