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
32 changes: 13 additions & 19 deletions src/ui/components/uicontroller/UiContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
import React, { type PropsWithChildren, ReactNode, useCallback, useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
import { Animated, AppState, Platform, StyleProp, View, ViewStyle } from 'react-native';
import { PlayerContext } from '../util/PlayerContext';
import { type TVOSEvent, useTVOSEventHandler } from '../util/TVUtils';
import type { AdEvent, PresentationModeChangeEvent, THEOplayer } from 'react-native-theoplayer';
import { AdEventType, CastEvent, CastEventType, ErrorEvent, PlayerError, PlayerEventType, PresentationMode } from 'react-native-theoplayer';
import { AdEventType, CastEvent, CastEventType, PlayerEventType, PresentationMode } from 'react-native-theoplayer';
import type { THEOplayerTheme } from '../../THEOplayerTheme';
import type { MenuConstructor, UiControls } from './UiControls';
import { ErrorDisplay } from '../message/ErrorDisplay';
import { useError } from '../../hooks/useError';
import { type Locale, defaultLocale } from '../util/Locale';
import { useWebMouseEvents } from '../../hooks/useWebMouseEvents';
import { useThrottledCallback } from '../../hooks/useThrottledCallback';
Expand Down Expand Up @@ -192,7 +193,6 @@ export const UiContainer = forwardRef<UiContainerRef, UiContainerProps>((props,
const fadeAnimation = useRef(new Animated.Value(1)).current;
const [currentMenu, setCurrentMenu] = useState<React.ReactNode | undefined>(undefined);
const [uiVisible_, setUiVisible] = useState(true);
const [error, setError] = useState<PlayerError | undefined>(undefined);
const [didPlay, setDidPlay] = useState(false);
const [paused, setPaused] = useState(true);
const [casting, setCasting] = useState(false);
Expand Down Expand Up @@ -294,14 +294,6 @@ export const UiContainer = forwardRef<UiContainerRef, UiContainerProps>((props,
setPaused(player.paused);
};

const handleLoadStart = () => {
setError(undefined);
};

const handleError = (event: ErrorEvent) => {
setError(event.error);
};

const handleCastEvent = (event: CastEvent) => {
if (event.subType === CastEventType.CHROMECAST_STATE_CHANGE || event.subType === CastEventType.AIRPLAY_STATE_CHANGE) {
setCasting(event.state === 'connecting' || event.state === 'connected');
Expand All @@ -319,8 +311,6 @@ export const UiContainer = forwardRef<UiContainerRef, UiContainerProps>((props,
}
};

player.addEventListener(PlayerEventType.LOAD_START, handleLoadStart);
player.addEventListener(PlayerEventType.ERROR, handleError);
player.addEventListener(PlayerEventType.CAST_EVENT, handleCastEvent);
player.addEventListener(PlayerEventType.PLAY, handlePlay);
player.addEventListener(PlayerEventType.PLAYING, handlePlay);
Expand All @@ -332,8 +322,6 @@ export const UiContainer = forwardRef<UiContainerRef, UiContainerProps>((props,
setPip(player.presentationMode === 'picture-in-picture');

return () => {
player.removeEventListener(PlayerEventType.LOAD_START, handleLoadStart);
player.removeEventListener(PlayerEventType.ERROR, handleError);
player.removeEventListener(PlayerEventType.CAST_EVENT, handleCastEvent);
player.removeEventListener(PlayerEventType.PLAY, handlePlay);
player.removeEventListener(PlayerEventType.PLAYING, handlePlay);
Expand Down Expand Up @@ -402,10 +390,6 @@ export const UiContainer = forwardRef<UiContainerRef, UiContainerProps>((props,

const combinedUiContainerStyle = [UI_CONTAINER_STYLE, props.style];

if (error !== undefined) {
return <ErrorDisplay error={error} />;
}

if (Platform.OS !== 'web' && pip) {
return <></>;
}
Expand All @@ -424,6 +408,7 @@ export const UiContainer = forwardRef<UiContainerRef, UiContainerProps>((props,

return (
<PlayerContext.Provider value={{ player, style: props.theme, ui, adInProgress, locale: combinedLocale }}>
<ErrorGate>
{/* The View behind the UI, that is always visible. Typically contains a spinner to indicate buffering. */}
<View style={FULLSCREEN_CENTER_STYLE} pointerEvents={'none'}>
{props.behind}
Expand Down Expand Up @@ -491,6 +476,15 @@ export const UiContainer = forwardRef<UiContainerRef, UiContainerProps>((props,
</>
)}
</Animated.View>
</ErrorGate>
</PlayerContext.Provider>
);
});

function ErrorGate({ children }: PropsWithChildren) {
const error = useError();
if (error !== undefined) {
return <ErrorDisplay error={error} />;
}
return <>{children}</>;
}
1 change: 1 addition & 0 deletions src/ui/hooks/barrel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './useAirplay';
export * from './useChaptersTrack';
export * from './useChromecast';
export * from './useError';
export * from './useCurrentTime';
export { useDebouncedCallback as useDebounce } from './useDebouncedCallback';
export * from './useDebouncedValue';
Expand Down
39 changes: 39 additions & 0 deletions src/ui/hooks/useError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useContext, useEffect, useState } from 'react';
import { type ErrorEvent, type PlayerError, type PlayerEventMap, PlayerEventType } from 'react-native-theoplayer';
import { PlayerContext } from '../barrel';

const ERROR_CHANGE_EVENTS = [PlayerEventType.ERROR, PlayerEventType.SOURCE_CHANGE, PlayerEventType.LOAD_START] satisfies ReadonlyArray<keyof PlayerEventMap>;

type ErrorChangeEventType = (typeof ERROR_CHANGE_EVENTS)[number];

/**
* Returns the current player error, or `undefined` if there is no error.
* Automatically updates when an error occurs, and resets when a new source is loaded.
*
* This hook must only be used in a component mounted inside a {@link THEOplayerDefaultUi} or {@link UiContainer},
* or alternatively any other component that provides a {@link PlayerContext}.
*
* @group Hooks
*/
export const useError = (): PlayerError | undefined => {
const [error, setError] = useState<PlayerError | undefined>(undefined);
Comment thread
tvanlaerhoven marked this conversation as resolved.
const { player } = useContext(PlayerContext);

useEffect(() => {
if (!player) return;
const onEvent = (event: { type: ErrorChangeEventType }) => {
if (event.type === PlayerEventType.ERROR) {
setError((event as ErrorEvent).error);
} else {
setError(undefined);
}
};

player.addEventListener(ERROR_CHANGE_EVENTS, onEvent);
return () => {
player.removeEventListener(ERROR_CHANGE_EVENTS, onEvent);
};
}, [player]);
Comment on lines +18 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚩 Error state persists across player prop changes in useError hook

When the player prop changes, the useEffect in useError at src/ui/hooks/useError.ts:22-36 re-runs to register new listeners, but the error state (line 19) is never explicitly reset. If the previous player had an error, it will persist until a LOAD_START or SOURCE_CHANGE event fires on the new player. This was the same behavior in the old code (the error useState in UiContainer also persisted across player changes), so this is not a regression. However, for a public hook, consumers may find it surprising.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Not a regression — matches the previous UiContainer behavior exactly (as the review notes). In practice, the player instance doesn't change during a session's lifetime, so this edge case doesn't arise. If needed in the future, a setError(undefined) at the top of the effect would handle it.


return error;
};
Loading