Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/components/CustomVideoPlayer/CustomVideoPlayerProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ type CustomVideoPlayerContext = {
videoContainerRef: React.RefObject<HTMLDivElement | null>;
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;
Expand Down Expand Up @@ -64,6 +66,7 @@ export function CustomVideoPlayerProvider({ children }: PropsWithChildren) {
YOUTUBE_PLAYER_STATES.BUFFERING,
);
const [manualPlayerState, setManualPlayerState] = useState(playerState);
const [playerErrorCode, setPlayerErrorCode] = useState<number | null>(null);

const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [videoProgress, setVideoProgress] = useState(0);
Expand Down Expand Up @@ -200,13 +203,24 @@ 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),
);
},
[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;
Expand Down Expand Up @@ -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;
Expand All @@ -382,9 +398,11 @@ export function CustomVideoPlayerProvider({ children }: PropsWithChildren) {
videoContainerRef,
onStateChange,
onPlayerReady,
onPlayerError,
toggleVideoPlayback,
manualPlayerState,
playerState,
playerErrorCode,
playbackSpeed,
changeVideoPlaybackSpeed,
videoDuration,
Expand Down
49 changes: 49 additions & 0 deletions src/components/CustomVideoPlayer/YouTubePlayerErrorOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="absolute inset-0 z-40 h-full w-full">
<div className="absolute inset-0 h-full w-full bg-black">
<img
className={clsx("h-full w-full", {
"object-cover": orientation === "vertical",
"object-contain": orientation === "horizontal",
})}
alt=""
src={`https://img.youtube.com/vi/${videoID}/maxresdefault.jpg`}
/>
</div>
<button

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/button-has-type (warning)

Your users can submit the form by accident because a <button> with no type defaults to submit.

Fix → Set an explicit button type so plain buttons do not submit forms by accident: type="button", "submit", or "reset".

Docs

aria-label={errorMessage}
onClick={onWatchOnYouTube}
className={clsx(
"absolute inset-0 inset-y-8 z-20 grid place-items-center",
{
"cursor-pointer": !isInactive,
"cursor-none!": isInactive,
},
)}
>
<div className="bg-background flex min-h-20 max-w-[calc(100%-2rem)] flex-wrap items-center justify-center gap-2 rounded-2xl px-4 text-sm font-medium shadow-2xl transition-all group-hover:scale-105 sm:px-6 sm:text-base">
<span>Watch on YouTube</span>
<ExternalLinkIcon size={18} />
</div>
</button>
</div>
);
}
33 changes: 33 additions & 0 deletions src/components/CustomVideoPlayer/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
};
95 changes: 65 additions & 30 deletions src/components/CustomVideoPlayer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -66,8 +69,10 @@ function CustomVideoPlayerContent(props: IResponsiveVideoProps) {
playerRef,
onStateChange,
onPlayerReady,
onPlayerError,
toggleVideoPlayback,
manualPlayerState,
playerErrorCode,
playbackSpeed,
changeVideoPlaybackSpeed,
videoDuration,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -182,41 +204,45 @@ function CustomVideoPlayerContent(props: IResponsiveVideoProps) {
}}
onStateChange={onStateChange}
onReady={onPlayerReady}
onError={onPlayerError}
loading="eager"
/>
<div className="group">
<div
className={clsx("transition-all", {
"opacity-0":
manualPlayerState === YOUTUBE_PLAYER_STATES.PLAYING ||
manualPlayerState === YOUTUBE_PLAYER_STATES.HELD ||
isSeeking,
})}
>
<div className="absolute inset-0 h-full w-full bg-black">
<img
className={clsx("h-full w-full", {
"object-cover": props.orientation === "vertical",
"object-contain": props.orientation === "horizontal",
})}
src={`https://img.youtube.com/vi/${props.videoID}/maxresdefault.jpg`}
/>
</div>
<button
onClick={toggleVideoPlayback}
className={clsx(
"absolute inset-0 inset-y-8 z-20 grid place-items-center",
{
"cursor-pointer": !props.isInactive,
"cursor-none!": props.isInactive,
},
)}
{!hasYouTubePlayerError && (
<div
className={clsx("transition-all", {
"opacity-0":
manualPlayerState === YOUTUBE_PLAYER_STATES.PLAYING ||
manualPlayerState === YOUTUBE_PLAYER_STATES.HELD ||
isSeeking,
})}
>
<div className="bg-background grid size-20 place-items-center rounded-2xl shadow-2xl transition-all group-hover:scale-105">
<PlayIcon size={32} />
<div className="absolute inset-0 h-full w-full bg-black">
<img
className={clsx("h-full w-full", {
"object-cover": props.orientation === "vertical",
"object-contain": props.orientation === "horizontal",
})}
alt=""
src={`https://img.youtube.com/vi/${props.videoID}/maxresdefault.jpg`}
/>
</div>
</button>
</div>
<button
onClick={toggleVideoPlayback}
className={clsx(
"absolute inset-0 inset-y-8 z-20 grid place-items-center",
{
"cursor-pointer": !props.isInactive,
"cursor-none!": props.isInactive,
},
)}
>
<div className="bg-background grid size-20 place-items-center rounded-2xl shadow-2xl transition-all group-hover:scale-105">
<PlayIcon size={32} />
</div>
</button>
</div>
)}
<div
className={clsx(
"dark absolute inset-y-0 right-0 z-30 flex flex-col items-end justify-center bg-gradient-to-l from-black/50 from-70% to-transparent p-4 pl-8 text-white opacity-0 transition-opacity",
Expand Down Expand Up @@ -469,6 +495,15 @@ function CustomVideoPlayerContent(props: IResponsiveVideoProps) {
className="mt-2 mr-4"
/>
</div>
{hasYouTubePlayerError && (
<YouTubePlayerErrorOverlay
errorMessage={playerErrorMessage}
isInactive={props.isInactive}
onWatchOnYouTube={openOriginalVideoUrl}
orientation={props.orientation}
videoID={props.videoID}
/>
)}
</div>
</>
)}
Expand Down
Loading
Loading