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
8 changes: 8 additions & 0 deletions .changeset/gentle-houses-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"react-native-youtube-bridge": patch
---

fix: where onProgress is not called when seekTo is invoked
- add TSDoc documentation
- add defensive logic for cases without videoId
- fix issue where seekTo doesn't work properly when paused without interval
71 changes: 66 additions & 5 deletions src/YoutubePlayer.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useWindowDimensions } from 'react-native';
import YoutubePlayerWrapper from './YoutubePlayerWrapper';
import type { YouTubePlayer } from './types/iframe';
import { ERROR_CODES, type PlayerControls, PlayerState, type YoutubePlayerProps } from './types/youtube';
import { validateVideoId } from './utils/validate';

const YoutubePlayer = forwardRef<PlayerControls, YoutubePlayerProps>(
(
Expand Down Expand Up @@ -40,6 +41,7 @@ const YoutubePlayer = forwardRef<PlayerControls, YoutubePlayerProps>(
const createPlayerRef = useRef<() => void>(null);
const progressInterval = useRef<NodeJS.Timeout | null>(null);
const intervalRef = useRef<number>(interval);
const seekTimeoutRef = useRef<NodeJS.Timeout | null>(null);

const stopProgressTracking = useCallback(() => {
if (progressInterval.current) {
Expand All @@ -48,6 +50,24 @@ const YoutubePlayer = forwardRef<PlayerControls, YoutubePlayerProps>(
}
}, []);

const sendProgress = useCallback(async () => {
if (!playerRef.current || !playerRef.current.getCurrentTime) {
return;
}

const currentTime = await playerRef.current.getCurrentTime();
const duration = await playerRef.current.getDuration();
const percentage = duration > 0 ? (currentTime / duration) * 100 : 0;
const loadedFraction = await playerRef.current.getVideoLoadedFraction();

onProgress?.({
currentTime,
duration,
percentage,
loadedFraction,
});
}, [onProgress]);

const startProgressTracking = useCallback(() => {
if (!intervalRef.current) {
return;
Expand Down Expand Up @@ -120,7 +140,12 @@ const YoutubePlayer = forwardRef<PlayerControls, YoutubePlayerProps>(
}, []);

createPlayerRef.current = () => {
if (!containerRef.current || !window.YT?.Player || !videoId) {
if (!containerRef.current || !window.YT?.Player) {
return;
}

if (!validateVideoId(videoId)) {
onError?.({ code: -2, message: 'Invalid YouTube videoId supplied' });
return;
}

Expand Down Expand Up @@ -171,10 +196,35 @@ const YoutubePlayer = forwardRef<PlayerControls, YoutubePlayerProps>(
const state = event.data;
console.log('YouTube player state changed:', state);
onStateChange?.(state);

if (state === PlayerState.ENDED) {
stopProgressTracking();
sendProgress();
return;
}

if (state === PlayerState.PLAYING) {
startProgressTracking();
return;
}

if (state === PlayerState.PAUSED) {
stopProgressTracking();
sendProgress();
return;
}

if (state === PlayerState.BUFFERING) {
startProgressTracking();
return;
}

if (state === PlayerState.CUED) {
stopProgressTracking();
sendProgress();
return;
}

stopProgressTracking();
},
onError: (event) => {
Expand Down Expand Up @@ -225,7 +275,7 @@ const YoutubePlayer = forwardRef<PlayerControls, YoutubePlayerProps>(
}, [createPlayer, stopProgressTracking]);

useEffect(() => {
if (playerRef.current && videoId) {
if (playerRef.current && validateVideoId(videoId)) {
try {
playerRef.current.loadVideoById(videoId);
} catch (error) {
Expand Down Expand Up @@ -258,9 +308,20 @@ const YoutubePlayer = forwardRef<PlayerControls, YoutubePlayerProps>(
playerRef.current?.stopVideo();
}, []);

const seekTo = useCallback((seconds: number, allowSeekAhead = true) => {
playerRef.current?.seekTo(seconds, allowSeekAhead);
}, []);
const seekTo = useCallback(
(seconds: number, allowSeekAhead = true) => {
playerRef.current?.seekTo(seconds, allowSeekAhead);

if (seekTimeoutRef.current) {
clearTimeout(seekTimeoutRef.current);
}

seekTimeoutRef.current = setTimeout(() => {
sendProgress();
}, 200);
},
[sendProgress],
);

const setVolume = useCallback((volume: number) => {
playerRef.current?.setVolume(volume);
Expand Down
13 changes: 12 additions & 1 deletion src/hooks/useCreateLocalPlayerHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const useCreateLocalPlayerHtml = ({
var isDestroyed = false;

${youtubeIframeScripts.receiveMessage}
${youtubeIframeScripts.sendProgress}

function cleanup() {
isDestroyed = true;
Expand Down Expand Up @@ -133,7 +134,17 @@ const useCreateLocalPlayerHtml = ({
play: () => player && player.playVideo(),
pause: () => player && player.pauseVideo(),
stop: () => player && player.stopVideo(),
seekTo: (seconds, allowSeekAhead) => player && player.seekTo(seconds, allowSeekAhead !== false),
seekTo: (seconds, allowSeekAhead) => {
if (!player) {
return;
}

player.seekTo(seconds, allowSeekAhead !== false);

setTimeout(() => {
sendProgress();
}, 200);
},

setVolume: (volume) => player && player.setVolume(volume),
getVolume: () => player ? player.getVolume() : 0,
Expand Down
52 changes: 50 additions & 2 deletions src/hooks/youtubeIframeScripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ const stopProgressTracking = /* js */ `
}
`;

const sendProgress = /* js */ `
function sendProgress() {
if (player && player.getCurrentTime) {
try {
const currentTime = player.getCurrentTime();
const duration = player.getDuration();
const percentage = duration > 0 ? (currentTime / duration) * 100 : 0;
const loadedFraction = player.getVideoLoadedFraction();

window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'progress',
currentTime,
duration,
percentage,
loadedFraction,
}));
} catch (error) {
console.error('Final progress error:', error);
}
}
}
`;

const onPlayerReady = /* js */ `
function onPlayerReady(event) {
if (isDestroyed) {
Expand All @@ -62,7 +85,7 @@ const onPlayerReady = /* js */ `
}
`;

const onPlayerStateChange = /* js */ `
const onPlayerStateChange = /* js */ `
function onPlayerStateChange(event) {
if (isDestroyed) {
return;
Expand All @@ -74,11 +97,35 @@ const onPlayerStateChange = /* js */ `
state: event.data
}));

if (event.data === YT.PlayerState.ENDED) {
stopProgressTracking();
sendProgress();
return;
}

if (event.data === YT.PlayerState.PLAYING) {
startProgressTracking();
} else {
return;
}

if (event.data === YT.PlayerState.PAUSED) {
stopProgressTracking();
sendProgress();
return;
}

if (event.data === YT.PlayerState.BUFFERING) {
startProgressTracking();
return;
}

if (event.data === YT.PlayerState.CUED) {
stopProgressTracking();
sendProgress();
return;
}

stopProgressTracking();
} catch (error) {
console.error('onPlayerStateChange error:', error);
}
Expand Down Expand Up @@ -229,6 +276,7 @@ export const youtubeIframeScripts = {
startProgressTracking,
stopProgressTracking,
receiveMessage,
sendProgress,
onPlayerReady,
onPlayerStateChange,
onPlayerError,
Expand Down
32 changes: 25 additions & 7 deletions src/types/youtube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,33 @@ import type { WebViewProps } from 'react-native-webview';

export type YoutubePlayerVars = {
/**
* @description If the `muted` is not set to true when activating the `autoplay`, it may not work properly depending on browser policy. (https://developer.chrome.com/blog/autoplay)
* @description If the `muted` is not set to true when activating the `autoplay`,
* it may not work properly depending on browser policy. (https://developer.chrome.com/blog/autoplay)
*/
autoplay?: boolean;
/**
* @description If the `controls` is set to true, the player will display the controls.
*/
controls?: boolean;
/**
* @description If the `loop` is set to true, the player will loop the video.
*/
loop?: boolean;
/**
* @description If the `muted` is set to true, the player will be muted.
*/
muted?: boolean;
startTime?: number;
endTime?: number;
playsinline?: boolean;
rel?: boolean; // 관련 동영상 표시
origin?: string; // 보안을 위한 origin 설정
/**
* @description If the `rel` is set to true, the related videos will be displayed.
*/
rel?: boolean;
/**
* @description The origin of the player.
*/
origin?: string;
};

// YouTube IFrame API official documentation based
Expand All @@ -24,8 +40,6 @@ export type YoutubePlayerProps = {
height?: DimensionValue;
/**
* @description The interval (in milliseconds) at which `onProgress` callback is called.
* Must be a positive number to enable progress tracking.
* If not provided or set to 0/falsy value, progress tracking is disabled.
*/
progressInterval?: number;
style?: StyleProp<ViewStyle>;
Expand All @@ -43,12 +57,16 @@ export type YoutubePlayerProps = {
iframeStyle?: CSSProperties;

// Events
/**
* @description Callback function called when the player is ready.
*/
onReady?: (playerInfo: PlayerInfo) => void;
onStateChange?: (state: PlayerState) => void;
onError?: (error: YouTubeError) => void;
/**
* @description Callback function called at the specified `progressInterval`.
* Only invoked when `progressInterval` is provided as a positive number.
* @description Callback function called at the specified `progressInterval`
* or when `seekTo` is invoked. Only triggered when `progressInterval` is
* provided as a positive number.
*/
onProgress?: (progress: ProgressData) => void;
onPlaybackRateChange?: (playbackRate: number) => void;
Expand Down
4 changes: 2 additions & 2 deletions src/utils/validate.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export const validateVideoId = (videoId: string): boolean => {
export const validateVideoId = (videoId?: string): boolean => {
// YouTube video ID is 11 characters of alphanumeric and hyphen, underscore
const videoIdRegex = /^[a-zA-Z0-9_-]{11}$/;
return videoIdRegex.test(videoId);
return videoIdRegex.test(videoId ?? '');
};

export const extractVideoIdFromUrl = (url: string): string | null => {
Expand Down
Loading