Skip to content
Merged
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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ dist-sw/

.content-collections

CLAUDE.md
.claude

data/libsql
279 changes: 227 additions & 52 deletions src/components/CustomVideoPlayer/CustomVideoPlayerProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,24 @@ import {
} from "react";
import YouTube, { YouTubeEvent } from "react-youtube";
import { YouTubeVideoType, YOUTUBE_PLAYER_STATES } from "./constants";
import {
useCaptionsEnabled,
useCaptionSize,
useSetCaptionsEnabled,
useSetCaptionSize,
} from "~/lib/data/captions/store";

export type CaptionTrack = {
languageCode: string;
languageName: string;
displayName?: string;
};

type CustomVideoPlayerContext = {
playerRef: React.RefObject<YouTube | null>;
videoContainerRef: React.RefObject<HTMLDivElement | null>;
onStateChange: (event: YouTubeEvent) => void;
onPlayerReady: (event: YouTubeEvent) => void;
toggleVideoPlayback: () => void;
manualPlayerState: number;
playerState: number;
Expand All @@ -24,8 +38,19 @@ type CustomVideoPlayerContext = {
seekToSecond: (second: number) => void;
videoProgress: number;
videoType: YouTubeVideoType;
startVideoHold: () => void;
stopVideoHold: () => void;
captionsEnabled: boolean;
captionsAvailable: boolean;
captionsModuleLoaded: boolean;
toggleCaptions: () => void;
captionSize: number;
setCaptionSize: (size: number) => void;
captionTracks: CaptionTrack[];
currentCaptionTrack: CaptionTrack | null;
setCaptionTrack: (track: CaptionTrack) => void;
isNativeFullscreen: boolean;
toggleNativeFullscreen: () => void;
isMuted: boolean;
toggleMute: () => void;
};

const CustomVideoPlayerContext = createContext<CustomVideoPlayerContext | null>(
Expand All @@ -34,6 +59,7 @@ const CustomVideoPlayerContext = createContext<CustomVideoPlayerContext | null>(

export function CustomVideoPlayerProvider({ children }: PropsWithChildren) {
const playerRef = useRef<YouTube | null>(null);
const videoContainerRef = useRef<HTMLDivElement | null>(null);
const [playerState, setPlayerState] = useState<number>(
YOUTUBE_PLAYER_STATES.BUFFERING,
);
Expand All @@ -46,73 +72,200 @@ export function CustomVideoPlayerProvider({ children }: PropsWithChildren) {

const [videoType, setVideoType] = useState<YouTubeVideoType>("video");

const changeVideoPlaybackSpeed = useCallback((speed: number) => {
if (!playerRef?.current) return;
const player = playerRef?.current as YouTube | null;
// Use Zustand store for persistent caption preferences
const captionsEnabled = useCaptionsEnabled();
const setCaptionsEnabled = useSetCaptionsEnabled();
const captionSize = useCaptionSize();
const setCaptionSizeState = useSetCaptionSize();

setPlaybackSpeed(speed);
void player?.internalPlayer?.setPlaybackRate(speed);
}, []);
const [captionTracks, setCaptionTracks] = useState<CaptionTrack[]>([]);
const [currentCaptionTrack, setCurrentCaptionTrack] =
useState<CaptionTrack | null>(null);
const [captionsModuleLoaded, setCaptionsModuleLoaded] = useState(false);
const captionsModuleLoadedRef = useRef(false);
const pendingCaptionEnableRef = useRef(false);

// In an effort to prevent YouTube suggestions from playing in the embed,
// we "pause" the video manually
const videoHoldLocationRef = useRef<number | null>(null);
const videoHoldSpeedRef = useRef<number | null>(null);
const videoHoldTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Native fullscreen state
const [isNativeFullscreen, setIsNativeFullscreen] = useState(false);

const setHoldTimeout = () => {
if (!playerRef?.current) return null;
const player = playerRef.current;
// Mute state
const [isMuted, setIsMuted] = useState(false);

return setTimeout(async () => {
await player?.internalPlayer?.seekTo(videoHoldLocationRef.current);
videoHoldTimeoutRef.current = setHoldTimeout();
}, 0);
};

// in order to "hold" the video, we want to
// - mute the video
// - drop the playback speed super low
// - rewind the video every X period of time back to the hold location
const startVideoHold = useCallback(async () => {
const toggleMute = useCallback(() => {
if (!playerRef?.current) return;
const player = playerRef.current;
const player = playerRef.current.internalPlayer;

if (isMuted) {
player?.unMute();
setIsMuted(false);
} else {
player?.mute();
setIsMuted(true);
}
}, [isMuted]);

// Listen for fullscreen changes
useEffect(() => {
const handleFullscreenChange = () => {
setIsNativeFullscreen(!!document.fullscreenElement);
};

setManualPlayerState(YOUTUBE_PLAYER_STATES.HELD);
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () =>
document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);

videoHoldLocationRef.current =
await player?.internalPlayer?.getCurrentTime();
// Toggle native fullscreen - targets the video container
const toggleNativeFullscreen = useCallback(() => {
if (document.fullscreenElement) {
document.exitFullscreen().catch(console.error);
} else {
// Use the video container ref, fallback to player container
const container =
videoContainerRef.current ?? playerRef.current?.container;
if (container) {
container.requestFullscreen().catch(console.error);
}
}
}, []);

// Derived state: captions are available when tracks are loaded
const captionsAvailable = captionTracks.length > 0;

videoHoldSpeedRef.current = await player?.internalPlayer?.getPlaybackRate();
void player?.internalPlayer?.setPlaybackRate(0);
type YouTubePlayerInternal = any;

player.internalPlayer.mute();
// Fetch and store caption tracks from the player
const fetchCaptionTracks = useCallback((target?: YouTubePlayerInternal) => {
const player = target ?? playerRef?.current?.internalPlayer;
if (!player) return null;

videoHoldTimeoutRef.current = setHoldTimeout();
const tracks = player.getOption?.("captions", "tracklist");
if (tracks && tracks.length > 0) {
setCaptionTracks(tracks as CaptionTrack[]);
return tracks as CaptionTrack[];
}
return null;
}, []);

const stopVideoHold = useCallback(() => {
// Enable captions with a specific track or the default
const enableCaptionsWithTrack = useCallback(
(target?: YouTubePlayerInternal, tracks?: CaptionTrack[]) => {
const player = target ?? playerRef?.current?.internalPlayer;
if (!player || !tracks || tracks.length === 0) return;

// Set the first available track (or English if available)
const englishTrack = tracks.find(
(t: CaptionTrack) =>
t.languageCode === "en" || t.languageCode?.startsWith("en"),
);
const trackToUse = englishTrack ?? tracks[0];
if (trackToUse) {
setCurrentCaptionTrack(trackToUse);
player.setOption?.("captions", "track", {
languageCode: trackToUse.languageCode,
});
}
},
[],
);

// Handler for onApiChange event - called when captions module loads
const handleApiChange = useCallback(
(target: YouTubePlayerInternal) => {
// Check which modules are available
const modules = target.getOptions?.();
if (!modules || !modules.includes("captions")) return;

captionsModuleLoadedRef.current = true;
setCaptionsModuleLoaded(true);

// Fetch tracks (with a small delay as tracks may not be immediately available)
setTimeout(() => {
const tracks = fetchCaptionTracks(target);

// If we have a pending caption enable request, complete it now
if (pendingCaptionEnableRef.current && tracks && tracks.length > 0) {
pendingCaptionEnableRef.current = false;
enableCaptionsWithTrack(target, tracks);
}
// If captions are enabled (user preference) and tracks are available, auto-enable
else if (captionsEnabled && tracks && tracks.length > 0) {
enableCaptionsWithTrack(target, tracks);
}
}, 100);
},
[fetchCaptionTracks, enableCaptionsWithTrack, captionsEnabled],
);

// Set up the onApiChange listener when player is ready
const onPlayerReady = useCallback(
(event: YouTubeEvent) => {
event.target.addEventListener("onApiChange", () =>
handleApiChange(event.target),
);
},
[handleApiChange],
);

const setCaptionTrack = useCallback((track: CaptionTrack) => {
if (!playerRef?.current) return;
const player = playerRef.current;
const player = playerRef.current.internalPlayer;

player.internalPlayer.unMute();
setCurrentCaptionTrack(track);
player?.setOption?.("captions", "track", {
languageCode: track.languageCode,
});
}, []);

if (videoHoldSpeedRef.current) {
void player?.internalPlayer?.setPlaybackRate(videoHoldSpeedRef.current);
videoHoldSpeedRef.current = null;
const toggleCaptions = useCallback(() => {
if (!playerRef?.current) return;
const player = playerRef.current.internalPlayer;

const newCaptionsEnabled = !captionsEnabled;

if (newCaptionsEnabled) {
// If captions module is loaded and we have tracks, enable immediately
if (captionsModuleLoadedRef.current && captionTracks.length > 0) {
setCaptionsEnabled(true);
enableCaptionsWithTrack(player, captionTracks);
} else if (captionsModuleLoadedRef.current) {
// Module loaded but no tracks - try fetching again
const tracks = fetchCaptionTracks(player);
if (tracks && tracks.length > 0) {
setCaptionsEnabled(true);
enableCaptionsWithTrack(player, tracks);
}
} else {
// Module not loaded yet - mark pending and it will enable when onApiChange fires
pendingCaptionEnableRef.current = true;
}
} else {
// Disable captions by setting an empty track
setCaptionsEnabled(false);
setCurrentCaptionTrack(null);
player?.setOption?.("captions", "track", {});
}
}, [
captionsEnabled,
captionTracks,
enableCaptionsWithTrack,
fetchCaptionTracks,
]);

const setCaptionSize = useCallback((size: number) => {
if (!playerRef?.current) return;
const player = playerRef.current;

if (videoHoldTimeoutRef.current) {
clearTimeout(videoHoldTimeoutRef.current);
videoHoldTimeoutRef.current = null;
}
setCaptionSizeState(size);
void player.internalPlayer?.setOption("captions", "fontSize", size);
}, []);

if (videoHoldLocationRef.current) {
void player?.internalPlayer?.seekTo(videoHoldLocationRef.current);
videoHoldLocationRef.current = null;
}
const changeVideoPlaybackSpeed = useCallback((speed: number) => {
if (!playerRef?.current) return;
const player = playerRef?.current as YouTube | null;

setManualPlayerState(YOUTUBE_PLAYER_STATES.PLAYING);
setPlaybackSpeed(speed);
void player?.internalPlayer?.setPlaybackRate(speed);
}, []);

const firstPlayTimestampRef = useRef<number | null>(null);
Expand Down Expand Up @@ -191,6 +344,8 @@ export function CustomVideoPlayerProvider({ children }: PropsWithChildren) {
}
}, [playerState]);

const hasLoadedCaptionsModuleRef = useRef(false);

const onStateChange = useCallback(
(event: YouTubeEvent) => {
setVideoDuration(event.target.getDuration());
Expand All @@ -202,6 +357,13 @@ export function CustomVideoPlayerProvider({ children }: PropsWithChildren) {
}

if (event.data === YOUTUBE_PLAYER_STATES.PLAYING) {
// Load captions module when video starts playing (required for captions API)
if (!hasLoadedCaptionsModuleRef.current) {
hasLoadedCaptionsModuleRef.current = true;
// Load the captions module - this will trigger onApiChange
event.target.loadModule?.("captions");
}

setTimeout(() => {
setManualPlayerState(event.data);
setIsSeeking(false);
Expand All @@ -217,7 +379,9 @@ export function CustomVideoPlayerProvider({ children }: PropsWithChildren) {
<CustomVideoPlayerContext.Provider
value={{
playerRef,
videoContainerRef,
onStateChange,
onPlayerReady,
toggleVideoPlayback,
manualPlayerState,
playerState,
Expand All @@ -228,8 +392,19 @@ export function CustomVideoPlayerProvider({ children }: PropsWithChildren) {
seekToSecond,
videoProgress,
videoType,
startVideoHold,
stopVideoHold,
captionsEnabled,
captionsAvailable,
captionsModuleLoaded,
toggleCaptions,
captionSize,
setCaptionSize,
captionTracks,
currentCaptionTrack,
setCaptionTrack,
isNativeFullscreen,
toggleNativeFullscreen,
isMuted,
toggleMute,
}}
>
{children}
Expand Down
8 changes: 8 additions & 0 deletions src/components/CustomVideoPlayer/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,11 @@ export const YOUTUBE_FASTEST_SPEED =

export const YOUTUBE_VIDEO_TYPES = ["video", "live"] as const;
export type YouTubeVideoType = (typeof YOUTUBE_VIDEO_TYPES)[number];

export const YOUTUBE_CAPTION_SIZES = [
{ label: "Small", value: -1 },
{ label: "Normal", value: 0 },
{ label: "Large", value: 1 },
{ label: "Larger", value: 2 },
{ label: "Largest", value: 3 },
];
Loading
Loading