diff --git a/example/src/VideoPlayerExample.tsx b/example/src/VideoPlayerExample.tsx index 5467d4505..d2dbd62bd 100644 --- a/example/src/VideoPlayerExample.tsx +++ b/example/src/VideoPlayerExample.tsx @@ -19,7 +19,7 @@ const VideoPlayerExample: React.FC = () => { resizeMode="cover" /> -
+
{ }} useNativeControls resizeMode="cover" + volume={0.3} playsInSilentModeIOS={true} />
@@ -35,11 +36,11 @@ const VideoPlayerExample: React.FC = () => { ref={videoPlayerRef} style={{ width: 350, height: 250 }} source={{ - uri: "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + uri: "https://ia601903.us.archive.org/32/items/BigBuckBunny_328/BigBuckBunny_512kb.mp4", }} useNativeControls={false} posterSource={{ - uri: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg", + uri: "https://upload.wikimedia.org/wikipedia/commons/7/70/Big.Buck.Bunny.-.Opening.Screen.png", }} usePoster onPlaybackStatusUpdate={(status) => setPlayerState(status)} diff --git a/packages/core/jest-setup.js b/packages/core/jest-setup.js index f2d4e012c..89c7c2c54 100644 --- a/packages/core/jest-setup.js +++ b/packages/core/jest-setup.js @@ -30,3 +30,42 @@ jest.mock("expo-font", () => ({ isLoaded: jest.fn(() => true), isLoading: jest.fn(() => false), })); + +jest.mock("expo-audio", () => ({ + useAudioPlayer: jest.fn(() => ({ + loop: false, + volume: 1.0, + playing: false, + paused: true, + muted: false, + isLoaded: false, + isBuffering: false, + currentTime: 0, + duration: 0, + play: jest.fn(), + pause: jest.fn(), + seekTo: jest.fn(), + replace: jest.fn(), + addListener: jest.fn(() => ({ remove: jest.fn() })), + })), + setAudioModeAsync: jest.fn(() => Promise.resolve()), +})); + +jest.mock("expo-video", () => ({ + useVideoPlayer: jest.fn(() => ({ + loop: false, + muted: false, + volume: 1.0, + playbackRate: 1.0, + playing: false, + status: "idle", + currentTime: 0, + duration: 0, + bufferedPosition: 0, + play: jest.fn(), + pause: jest.fn(), + replace: jest.fn(), + addListener: jest.fn(() => ({ remove: jest.fn() })), + })), + VideoView: "VideoView", +})); diff --git a/packages/core/package.json b/packages/core/package.json index 72d24d72c..ed4e12c08 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -57,7 +57,8 @@ "color": "^4.2.3", "date-fns": "^4.1.0", "dateformat": "^5.0.3", - "expo-av": "~16.0.8", + "expo-video": "~3.0.16", + "expo-audio": "~1.1.1", "expo-image": "~3.0.11", "lodash.isequal": "^4.5.0", "lodash.isnumber": "^3.0.3", diff --git a/packages/core/src/__tests__/components/AudioPlayer.test.skip.tsx b/packages/core/src/__tests__/components/AudioPlayer.test.skip.tsx deleted file mode 100644 index 30dbec0d6..000000000 --- a/packages/core/src/__tests__/components/AudioPlayer.test.skip.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import React from "react"; -import { act, render, screen, fireEvent } from "@testing-library/react-native"; -import { - default as AudioPlayer, - AudioPlayerRef, -} from "../../components/MediaPlayer/AudioPlayer"; - -const mockAudioSource = { - uri: "audio-uri", -}; - -const mockPlayAsync = jest.fn(); -const mockPauseAsync = jest.fn(); -const mockUnloadAsync = jest.fn(); -const mockSetIsLoopAsync = jest.fn(); - -// To ignore the warning: 'When testing, code that causes React state updates should be wrapped into act' -// This is caused because state is updated in the useEffect, this is intentional and not an issue, so we can ignore it -console.error = jest.fn(); - -jest.mock("expo-av", () => { - const original = jest.requireActual("expo-av"); - - class Audio { - static setAudioModeAsync = () => {}; - static Sound = { - onPlaybackStatusUpdate: (_) => {}, - createAsync: function () { - return { - sound: { - setOnPlaybackStatusUpdate: (callback) => - (this.onPlaybackStatusUpdate = callback), - playAsync: () => { - this.onPlaybackStatusUpdate({ - isLoaded: true, - isPlaying: true, - }); - mockPlayAsync(); - }, - pauseAsync: mockPauseAsync, - unloadAsync: mockUnloadAsync, - setPositionAsync: (position: number) => { - this.onPlaybackStatusUpdate({ - isLoaded: true, - positionMillis: position, - }); - }, - setIsLoopingAsync: mockSetIsLoopAsync, - }, - }; - }, - }; - } - - return { ...original, Audio }; -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe.skip("AudioPlayer tests", () => { - test("should render an interface when in 'interface' mode", () => { - render(); - - const playerInterface = screen.queryByTestId("audio-player-interface"); - expect(playerInterface).toBeTruthy(); - }); - - test("should not render an interface when in 'headless' mode", () => { - render(); - - const playerInterface = screen.queryByTestId("audio-player-interface"); - () => expect(playerInterface).toBeFalsy(); - }); - - test("should render playback icon when hidePlaybackIcon is false", () => { - render(); - - const playbackIcon = screen.queryByTestId("audio-player-playback-icon"); - () => expect(playbackIcon).toBeTruthy(); - }); - - test("should not render playback icon when hidePlaybackIcon is true", () => { - render(); - - const playbackIcon = screen.queryByTestId("audio-player-playback-icon"); - () => expect(playbackIcon).toBeFalsy(); - }); - - test("should render duration when hideDuration is false", () => { - render(); - - const duration = screen.queryByTestId("audio-player-duration"); - () => expect(duration).toBeTruthy(); - }); - - test("should not render duration when hideDuration is true", () => { - render(); - - const duration = screen.queryByTestId("audio-player-duration"); - () => expect(duration).toBeFalsy(); - }); - - test("should render slider when hideSlider is false", () => { - render(); - - const slider = screen.queryByTestId("audio-player-slider"); - () => expect(slider).toBeTruthy(); - }); - - test("should not render slider when hideSlider is true", () => { - render(); - - const slider = screen.queryByTestId("audio-player-slider"); - () => expect(slider).toBeFalsy(); - }); - - test("should play and pause audio when clicking playback icon", async () => { - const ref = React.createRef(); - - render(); - - await waitForSoundToLoad(); - - const playbackIcon = await screen.findByTestId( - "audio-player-playback-icon" - ); - - act(() => { - fireEvent.press(playbackIcon); - }); - expect(mockPlayAsync).toBeCalled(); - - act(() => { - fireEvent.press(playbackIcon); - }); - expect(mockPauseAsync).toBeCalled(); - }); - - test("should togglePlayback play and pause audio", async () => { - const ref = React.createRef(); - - render(); - - await act(async () => { - await waitForSoundToLoad(); - ref.current?.togglePlayback(); - }); - expect(mockPlayAsync).toBeCalled(); - - act(() => { - ref.current?.togglePlayback(); - }); - expect(mockPauseAsync).toBeCalled(); - }); - - test("should play() play the audio", async () => { - const ref = React.createRef(); - - render(); - - await act(async () => { - await waitForSoundToLoad(); - ref.current?.play(); - }); - expect(mockPlayAsync).toBeCalled(); - }); - - test("should pause() pause the audio", async () => { - const ref = React.createRef(); - - render(); - - await act(async () => { - await waitForSoundToLoad(); - ref.current?.pause(); - }); - expect(mockPauseAsync).toBeCalled(); - }); - - test("should audio be cleaned up/unloaded when unmounting", async () => { - render(); - - await waitForSoundToLoad(); - screen.unmount(); - expect(mockUnloadAsync).toBeCalled(); - }); - - test("should seekToPosition change audio position", async () => { - const ref = React.createRef(); - const position = 30000; - const onPlaybackStatusUpdate = jest.fn(); - render( - - ); - - await act(async () => { - await waitForSoundToLoad(); - ref.current?.seekToPosition(position); - }); - expect(onPlaybackStatusUpdate).toBeCalledWith( - expect.objectContaining({ currentPositionMillis: position }) - ); - }); -}); - -async function waitForSoundToLoad() { - /** - * The sound/media object/reference is not instantly loaded in the Audio Player - * This delay is enough to make sure it is loaded - * - * This is the simplest way to do it since mocks are being used and nothing is actually being 'loaded', just prevents it from being instant - */ - await new Promise((r) => setTimeout(r, 500)); -} diff --git a/packages/core/src/__tests__/components/VideoPlayer.test.skip.tsx b/packages/core/src/__tests__/components/VideoPlayer.test.skip.tsx deleted file mode 100644 index 60d56eac5..000000000 --- a/packages/core/src/__tests__/components/VideoPlayer.test.skip.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import React from "react"; -import { act, render, screen } from "@testing-library/react-native"; -import { - default as VideoPlayer, - VideoPlayerRef, -} from "../../components/MediaPlayer/VideoPlayer"; - -const mockPlayAsync = jest.fn(); -const mockPauseAsync = jest.fn(); -const mockUnloadAsync = jest.fn(); -const mockPresentFullscreenPlayer = jest.fn(); -const mockDismissFullscreenPlayer = jest.fn(); - -jest.mock("expo-av", () => { - const original = jest.requireActual("expo-av"); - const React = require("react"); - - class Video extends React.Component { - render(): React.ReactNode { - return <>; - } - playAsync = () => { - this.props.onPlaybackStatusUpdate({ isLoaded: true, isPlaying: true }); - mockPlayAsync(); - }; - pauseAsync = mockPauseAsync; - unloadAsync = mockUnloadAsync; - setPositionAsync = (position: number) => { - this.props.onPlaybackStatusUpdate({ - isLoaded: true, - positionMillis: position, - }); - }; - presentFullscreenPlayer = () => { - this.props.onFullscreenUpdate({ - fullscreenUpdate: original.VideoFullscreenUpdate.PLAYER_DID_PRESENT, - }); - mockPresentFullscreenPlayer(); - }; - dismissFullscreenPlayer = mockDismissFullscreenPlayer; - } - return { ...original, Video }; -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe("VideoPlayer tests", () => { - test("should togglePlayback play and pause video", async () => { - const ref = React.createRef(); - - render( - - ); - - act(() => { - ref.current?.togglePlayback(); - }); - expect(mockPlayAsync).toBeCalled(); - - act(() => { - ref.current?.togglePlayback(); - }); - expect(mockPauseAsync).toBeCalled(); - }); - - test("should play() play the video", async () => { - const ref = React.createRef(); - - render( - - ); - - act(() => { - ref.current?.play(); - }); - expect(mockPlayAsync).toBeCalled(); - }); - - test("should pause() pause the video", async () => { - const ref = React.createRef(); - - render( - - ); - - act(() => { - ref.current?.pause(); - }); - expect(mockPauseAsync).toBeCalled(); - }); - - test("should video be cleaned up/unloaded when unmounting", async () => { - render( - - ); - screen.unmount(); - expect(mockUnloadAsync).toBeCalled(); - }); - - test("should seekToPosition change video position", async () => { - const ref = React.createRef(); - const position = 30000; - const onPlaybackStatusUpdate = jest.fn(); - render( - - ); - - act(() => { - ref.current?.seekToPosition(position); - }); - expect(onPlaybackStatusUpdate).toBeCalledWith( - expect.objectContaining({ currentPositionMillis: position }) - ); - }); - - test("should toggleFullscreen open and dismiss full screen", async () => { - const ref = React.createRef(); - - render( - - ); - - act(() => { - ref.current?.toggleFullscreen(); - }); - expect(mockPresentFullscreenPlayer).toBeCalled(); - - act(() => { - ref.current?.toggleFullscreen(); - }); - expect(mockDismissFullscreenPlayer).toBeCalled(); - }); -}); diff --git a/packages/core/src/components/MediaPlayer/AudioPlayer/HeadlessAudioPlayer.tsx b/packages/core/src/components/MediaPlayer/AudioPlayer/HeadlessAudioPlayer.tsx index ba5c7c43e..a31080927 100644 --- a/packages/core/src/components/MediaPlayer/AudioPlayer/HeadlessAudioPlayer.tsx +++ b/packages/core/src/components/MediaPlayer/AudioPlayer/HeadlessAudioPlayer.tsx @@ -1,17 +1,12 @@ import * as React from "react"; -import { - Audio, - AVPlaybackStatus, - InterruptionModeIOS, - InterruptionModeAndroid, -} from "expo-av"; +import { useAudioPlayer, setAudioModeAsync, AudioStatus } from "expo-audio"; import { HeadlessAudioPlayerProps } from "./AudioPlayerCommon"; import { - mapToMediaPlayerStatus, normalizeBase64Source, + useSourceDeepCompareMemoize, useSourceDeepCompareEffect, } from "../MediaPlayerCommon"; -import type { MediaPlayerRef } from "../MediaPlayerCommon"; +import type { MediaPlayerRef, MediaPlayerStatus } from "../MediaPlayerCommon"; import MediaPlaybackWrapper from "../MediaPlaybackWrapper"; /** @@ -36,38 +31,70 @@ const HeadlessAudioPlayer = React.forwardRef< }, ref ) => { - const [currentSound, setCurrentSound] = React.useState(); + const stableSource = useSourceDeepCompareMemoize( + normalizeBase64Source(source, "audio") + ); + const player = useAudioPlayer(stableSource); + const [isPlaying, setIsPlaying] = React.useState(false); React.useEffect(() => { - if ( - currentSound && - typeof currentSound?.setIsLoopingAsync === "function" - ) { - currentSound.setIsLoopingAsync(isLooping); - } - }, [currentSound, isLooping]); + player.loop = isLooping; + }, [player, isLooping]); React.useEffect(() => { - if (currentSound && typeof currentSound?.setVolumeAsync === "function") { - currentSound.setVolumeAsync(volume); + player.volume = volume; + }, [player, volume]); + + // Emit loading state immediately + React.useEffect(() => { + onPlaybackStatusUpdateProp?.({ + isPlaying: false, + isLoading: true, + isBuffering: false, + currentPositionMillis: 0, + durationMillis: 0, + bufferedDurationMillis: 0, + isError: false, + }); + }, []); + + React.useEffect(() => { + const subscription = player.addListener( + "playbackStatusUpdate", + (status) => { + const mappedStatus = mapToMediaPlayerStatus(status); + onPlaybackStatusUpdateProp?.(mappedStatus); + + if (status.isLoaded) { + if (status.didJustFinish && !isLooping) { + onPlaybackFinish?.(); + } + setIsPlaying(status.playing); + } + } + ); + return () => subscription.remove(); + }, []); + + // Replace source when it changes (deep comparison on URI to avoid unnecessary reloads) + const isFirstSourceRender = React.useRef(true); + useSourceDeepCompareEffect(() => { + if (isFirstSourceRender.current) { + isFirstSourceRender.current = false; + return; } - }, [currentSound, volume]); + player.replace(normalizeBase64Source(source, "audio") as any); + }, [source]); const updateAudioMode = React.useCallback(async () => { try { - await Audio.setAudioModeAsync({ - staysActiveInBackground: playsInBackground, - interruptionModeIOS: - interruptionMode === "lower volume" - ? InterruptionModeIOS.DuckOthers - : InterruptionModeIOS.DoNotMix, - interruptionModeAndroid: - interruptionMode === "lower volume" - ? InterruptionModeAndroid.DuckOthers - : InterruptionModeAndroid.DoNotMix, - playsInSilentModeIOS, - playThroughEarpieceAndroid, + await setAudioModeAsync({ + shouldPlayInBackground: playsInBackground, + interruptionMode: + interruptionMode === "lower volume" ? "duckOthers" : "doNotMix", + playsInSilentMode: playsInSilentModeIOS, + shouldRouteThroughEarpiece: playThroughEarpieceAndroid, }); } catch (e) { if ((e as { code?: string })?.code === "E_AUDIO_AUDIOMODE") { @@ -88,59 +115,44 @@ const HeadlessAudioPlayer = React.forwardRef< playThroughEarpieceAndroid, ]); - const onPlaybackStatusUpdate = (status: AVPlaybackStatus) => { - const mappedStatus = mapToMediaPlayerStatus(status); - onPlaybackStatusUpdateProp?.(mappedStatus); - - if (status.isLoaded) { - if (status.didJustFinish) { - if (isLooping) { - return; - } - onPlaybackFinish?.(); - } - setIsPlaying(status.isPlaying); - } - }; - const onTogglePlayback = () => { - //Has to be called everytime a player is played to reconfigure the global Audio config based on each player's configuration + // Has to be called everytime a player is played to reconfigure the global Audio config based on each player's configuration updateAudioMode(); }; - const loadAudio = async () => { - onPlaybackStatusUpdateProp?.({ - isPlaying: false, - isLoading: true, - isBuffering: false, - currentPositionMillis: 0, - durationMillis: 0, - bufferedDurationMillis: 0, - isError: false, - }); - - const finalSource = await normalizeBase64Source(source, "audio"); - - const { sound } = await Audio.Sound.createAsync(finalSource); - setCurrentSound(sound); - sound.setOnPlaybackStatusUpdate(onPlaybackStatusUpdate); - }; - - useSourceDeepCompareEffect(() => { - loadAudio(); - - // Ignore dependency of loadAudio - }, [source]); - return ( ); } ); +export function mapToMediaPlayerStatus(status: AudioStatus): MediaPlayerStatus { + if (status.isLoaded) { + return { + isPlaying: status.playing, + isLoading: false, + isBuffering: status.isBuffering, + currentPositionMillis: status.currentTime * 1000, + durationMillis: status.duration * 1000, + bufferedDurationMillis: status.duration * 1000, + isError: false, + }; + } + + return { + isPlaying: false, + isLoading: true, + isBuffering: false, + currentPositionMillis: 0, + durationMillis: 0, + bufferedDurationMillis: 0, + isError: false, + }; +} + export default HeadlessAudioPlayer; diff --git a/packages/core/src/components/MediaPlayer/MediaPlaybackWrapper.tsx b/packages/core/src/components/MediaPlayer/MediaPlaybackWrapper.tsx index 3ffac4145..39b95a733 100644 --- a/packages/core/src/components/MediaPlayer/MediaPlaybackWrapper.tsx +++ b/packages/core/src/components/MediaPlayer/MediaPlaybackWrapper.tsx @@ -1,10 +1,11 @@ import * as React from "react"; -import type { Playback } from "expo-av/src/AV"; +import type { AudioPlayer } from "expo-audio"; +import type { VideoPlayer } from "expo-video"; import type { MediaPlayerRef } from "./MediaPlayerCommon"; interface MediaPlaybackWrapperProps { - media?: Playback; + player?: AudioPlayer | VideoPlayer; isPlaying?: boolean; onTogglePlayback?: () => void; } @@ -15,42 +16,38 @@ interface MediaPlaybackWrapperProps { const MediaPlaybackWrapper = React.forwardRef< MediaPlayerRef, React.PropsWithChildren ->(({ media, isPlaying, onTogglePlayback, children }, ref) => { - const togglePlayback = React.useCallback(async () => { +>(({ player, isPlaying, onTogglePlayback, children }, ref) => { + const togglePlayback = React.useCallback(() => { onTogglePlayback?.(); if (isPlaying) { - await media?.pauseAsync(); + player?.pause(); } else { - await media?.playAsync(); + player?.play(); } - }, [media, isPlaying, onTogglePlayback]); + }, [isPlaying, onTogglePlayback]); - const pause = React.useCallback(async () => { + const pause = React.useCallback(() => { onTogglePlayback?.(); - await media?.pauseAsync(); - }, [media, onTogglePlayback]); + player?.pause(); + }, [player, onTogglePlayback]); - const play = React.useCallback(async () => { + const play = React.useCallback(() => { onTogglePlayback?.(); - await media?.playAsync(); - }, [media, onTogglePlayback]); + player?.play(); + }, [player, onTogglePlayback]); const seekToPosition = React.useCallback( - async (positionMillis: number) => { - await media?.setPositionAsync(positionMillis); + (positionMillis: number) => { + if (typeof (player as any)?.seekTo === "function") { + (player as AudioPlayer).seekTo(positionMillis / 1000); + } else if (player) { + player.currentTime = positionMillis / 1000; + } }, - [media] + [player] ); - React.useEffect(() => { - return media - ? () => { - media.unloadAsync(); - } - : undefined; - }, [media]); - React.useImperativeHandle( ref, () => ({ diff --git a/packages/core/src/components/MediaPlayer/MediaPlayerCommon.ts b/packages/core/src/components/MediaPlayer/MediaPlayerCommon.ts index 7aba6f632..00a39f22a 100644 --- a/packages/core/src/components/MediaPlayer/MediaPlayerCommon.ts +++ b/packages/core/src/components/MediaPlayer/MediaPlayerCommon.ts @@ -1,4 +1,5 @@ -import { AVPlaybackSource, AVPlaybackStatus } from "expo-av"; +import { AudioSource } from "expo-audio"; +import { VideoSource } from "expo-video"; import { v4 as uuid } from "uuid"; import { Platform } from "react-native"; import React from "react"; @@ -24,34 +25,7 @@ export interface MediaPlayerRef { export interface MediaPlayerProps { onPlaybackStatusUpdate?: (status: MediaPlayerStatus) => void; onPlaybackFinish?: () => void; - source: AVPlaybackSource; -} - -export function mapToMediaPlayerStatus( - status: AVPlaybackStatus -): MediaPlayerStatus { - if (status.isLoaded) { - return { - isPlaying: status.isPlaying, - isLoading: false, - isBuffering: status.isBuffering, - currentPositionMillis: status.positionMillis || 0, - durationMillis: status.durationMillis || 0, - bufferedDurationMillis: status.playableDurationMillis || 0, - isError: false, - }; - } - - return { - isPlaying: false, - isLoading: false, - isBuffering: false, - currentPositionMillis: 0, - durationMillis: 0, - bufferedDurationMillis: 0, - isError: true, - error: status.error, - }; + source: AudioSource | VideoSource; } const URL_REGEX = @@ -60,14 +34,14 @@ const URL_REGEX = /** * Base64 strings are not playable on iOS and needs to be saved to a file before playing */ -export async function normalizeBase64Source( - source: AVPlaybackSource, +export function normalizeBase64Source( + source: AudioSource | VideoSource, type: "audio" | "video" -): Promise { +): AudioSource | VideoSource { const uri: string | undefined = (source as any)?.uri; if (Platform.OS === "ios" && uri && !uri.match(URL_REGEX)) { - const { File, Paths } = await import("expo-file-system"); + const { File, Paths } = require("expo-file-system"); const defaultMimeType = type === "audio" ? "wav" : "mp4"; const mimeType = uri.startsWith(`data:${type}/`) @@ -100,7 +74,7 @@ function sourceDeepCompareEquals(a: any, b: any) { return a === b; } -function useSourceDeepCompareMemoize(value: any) { +export function useSourceDeepCompareMemoize(value: any) { const ref = React.useRef(undefined); if (!sourceDeepCompareEquals(value, ref.current)) { ref.current = value; diff --git a/packages/core/src/components/MediaPlayer/VideoPlayer/VideoPlayer.tsx b/packages/core/src/components/MediaPlayer/VideoPlayer/VideoPlayer.tsx index 21e9f4723..7edf026f8 100644 --- a/packages/core/src/components/MediaPlayer/VideoPlayer/VideoPlayer.tsx +++ b/packages/core/src/components/MediaPlayer/VideoPlayer/VideoPlayer.tsx @@ -1,136 +1,230 @@ import React from "react"; -import { ImageResizeMode, Platform } from "react-native"; import { - Video as VideoPlayerComponent, - VideoProps as ExpoVideoProps, - ResizeMode as ExpoResizeMode, - AVPlaybackStatus, - VideoFullscreenUpdate, - AVPlaybackSource, - Audio, -} from "expo-av"; + Image, + ImageProps, + ImageResizeMode, + StyleSheet, + View, +} from "react-native"; +import { + VideoView as VideoPlayerComponent, + VideoViewProps as ExpoVideoProps, + VideoContentFit, + useVideoPlayer, + TimeUpdateEventPayload, + VideoPlayer as VideoPlayerType, + VideoSource, +} from "expo-video"; +import { setAudioModeAsync } from "expo-audio"; import { extractSizeStyles } from "../../../utilities"; import MediaPlaybackWrapper from "../MediaPlaybackWrapper"; -import type { Playback } from "expo-av/src/AV"; import { - mapToMediaPlayerStatus, normalizeBase64Source, useSourceDeepCompareEffect, + useSourceDeepCompareMemoize, +} from "../MediaPlayerCommon"; +import type { + MediaPlayerRef, + MediaPlayerProps, + MediaPlayerStatus, } from "../MediaPlayerCommon"; -import type { MediaPlayerRef, MediaPlayerProps } from "../MediaPlayerCommon"; type ResizeMode = "contain" | "cover" | "stretch"; -type ExpoVideoPropsOmitted = Omit< - ExpoVideoProps, - "videoStyle" | "resizeMode" | "onPlaybackStatusUpdate" | "source" ->; +type ExpoVideoPropsOmitted = Omit; interface VideoPlayerProps extends ExpoVideoPropsOmitted, MediaPlayerProps { resizeMode?: ResizeMode; posterResizeMode?: ImageResizeMode; + posterSource?: ImageProps["source"]; + usePoster?: boolean; playsInSilentModeIOS?: boolean; + isMuted?: boolean; + useNativeControls?: boolean; + shouldPlay?: boolean; + isLooping?: boolean; + positionMillis?: number; + rate?: number; + volume?: number; } export interface VideoPlayerRef extends MediaPlayerRef { toggleFullscreen: () => void; } -// Setting playsInSilentModeIOS prop directly on Video component is unreliable, -// so we need to set the audio mode globally before playing. -// See: -// https://github.com/expo/expo/issues/7485 -// https://stackoverflow.com/questions/57371543/how-to-fix-video-play-but-dont-have-sound-on-ios-with-expo -const triggerAudio = async (ref: React.RefObject) => { - if (ref && ref?.current && Platform.OS === "ios") { - await Audio.setAudioModeAsync({ playsInSilentModeIOS: true }); - ref.current.play(); - } -}; - const VideoPlayer = React.forwardRef( ( { style, resizeMode = "contain", posterResizeMode = "cover", + posterSource, + usePoster = false, onPlaybackStatusUpdate: onPlaybackStatusUpdateProp, onPlaybackFinish, source, playsInSilentModeIOS = false, + isMuted = false, + useNativeControls = true, + shouldPlay = false, + isLooping = false, + positionMillis, + allowsFullscreen = true, + rate = 1, + volume = 1, ...rest }, ref ) => { - const [videoMediaObject, setVideoMediaObject] = - React.useState(); + const stableSource = useSourceDeepCompareMemoize( + normalizeBase64Source(source, "video") + ); + + const player = useVideoPlayer(stableSource, (p) => { + p.loop = isLooping; + p.muted = isMuted; + p.volume = volume; + p.playbackRate = rate; + }); + + const videoPlayerRef = React.useRef(null); const [isPlaying, setIsPlaying] = React.useState(false); const [isFullscreen, setIsFullscreen] = React.useState(false); - const [currentSource, setCurrentSource] = - React.useState(); + const [showPoster, setShowPoster] = React.useState( + usePoster && !!posterSource + ); + const mediaPlaybackWrapperRef = React.useRef(null); const sizeStyles = extractSizeStyles(style); - let mappedResizeMode; + React.useEffect(() => { + player.muted = isMuted; + }, [player, isMuted]); + + React.useEffect(() => { + player.loop = isLooping; + }, [player, isLooping]); + + React.useEffect(() => { + player.volume = volume; + }, [player, volume]); + + React.useEffect(() => { + player.playbackRate = rate; + }, [player, rate]); + + // Refs so statusChange can read latest shouldPlay/positionMillis + const shouldPlayRef = React.useRef(shouldPlay); + const positionMillisRef = React.useRef(positionMillis); + shouldPlayRef.current = shouldPlay; + positionMillisRef.current = positionMillis; + + const hasAppliedInitialState = React.useRef(false); + + React.useEffect(() => { + const timeUpdateSub = player.addListener("timeUpdate", (status) => { + onPlaybackStatusUpdateProp?.(mapToMediaPlayerStatus(status, player)); + }); + + const playingChangeSub = player.addListener( + "playingChange", + ({ isPlaying: playing }) => { + setIsPlaying(playing); + onPlaybackStatusUpdateProp?.(mapPlayerToMediaPlayerStatus(player)); + } + ); + + const playToEndSub = player.addListener("playToEnd", () => { + onPlaybackFinish?.(); + }); + + const statusChangeSub = player.addListener( + "statusChange", + ({ status, error }) => { + if (status === "readyToPlay") { + setShowPoster(false); + if (!hasAppliedInitialState.current) { + hasAppliedInitialState.current = true; + if (positionMillisRef.current) { + player.currentTime = positionMillisRef.current / 1000; + } + if (shouldPlayRef.current) { + player.play(); + } + } + } + const mappedStatus = mapPlayerToMediaPlayerStatus(player); + onPlaybackStatusUpdateProp?.( + status === "error" && error + ? { ...mappedStatus, isError: true, error: error.message } + : mappedStatus + ); + } + ); + + return () => { + timeUpdateSub.remove(); + playingChangeSub.remove(); + playToEndSub.remove(); + statusChangeSub.remove(); + }; + }, []); + + // Replace video source when it changes (deep comparison on URI to avoid unnecessary reloads) + const isFirstSourceRender = React.useRef(true); + useSourceDeepCompareEffect(() => { + if (isFirstSourceRender.current) { + isFirstSourceRender.current = false; + return; + } + hasAppliedInitialState.current = false; + player.replace(normalizeBase64Source(source, "video") as VideoSource); + }, [source]); + + let mappedVideoContentFit: VideoContentFit; switch (resizeMode) { case "contain": - mappedResizeMode = ExpoResizeMode.CONTAIN; + mappedVideoContentFit = "contain"; break; case "cover": - mappedResizeMode = ExpoResizeMode.COVER; + mappedVideoContentFit = "cover"; break; case "stretch": - mappedResizeMode = ExpoResizeMode.STRETCH; + mappedVideoContentFit = "fill"; break; } - const onPlaybackStatusUpdate = (status: AVPlaybackStatus) => { - const mappedStatus = mapToMediaPlayerStatus(status); - onPlaybackStatusUpdateProp?.(mappedStatus); - - if (status.isLoaded) { - if (status.didJustFinish) { - onPlaybackFinish?.(); - } - setIsPlaying(status.isPlaying); - } - }; - - const onFullscreenUpdate = (fullscreenUpdate: VideoFullscreenUpdate) => { - switch (fullscreenUpdate) { - case VideoFullscreenUpdate.PLAYER_DID_PRESENT: - case VideoFullscreenUpdate.PLAYER_WILL_PRESENT: + const onFullscreenUpdate = (type: "entered" | "exited") => { + switch (type) { + case "entered": setIsFullscreen(true); break; - case VideoFullscreenUpdate.PLAYER_DID_DISMISS: - case VideoFullscreenUpdate.PLAYER_WILL_DISMISS: + case "exited": setIsFullscreen(false); break; } }; const toggleFullscreen = React.useCallback(async () => { - if (isFullscreen) { - await videoMediaObject?.dismissFullscreenPlayer(); - } else { - await videoMediaObject?.presentFullscreenPlayer(); + if (videoPlayerRef) { + if (isFullscreen) { + await videoPlayerRef.current?.exitFullscreen(); + } else { + await videoPlayerRef.current?.enterFullscreen(); + } } - }, [isFullscreen, videoMediaObject]); + }, [isFullscreen]); const updateAudioMode = React.useCallback(async () => { try { - await Audio.setAudioModeAsync({ - playsInSilentModeIOS, + await setAudioModeAsync({ + playsInSilentMode: playsInSilentModeIOS, }); } catch (e) { console.error("Failed to set audio mode. Error details:", e); } }, [playsInSilentModeIOS]); - React.useEffect(() => { - if (isPlaying) triggerAudio(mediaPlaybackWrapperRef); - }, [mediaPlaybackWrapperRef, isPlaying]); - React.useImperativeHandle( ref, () => ({ @@ -147,36 +241,69 @@ const VideoPlayer = React.forwardRef( [toggleFullscreen, isPlaying] ); - useSourceDeepCompareEffect(() => { - const updateSource = async () => { - const finalSource = await normalizeBase64Source(source, "video"); - setCurrentSource(finalSource); - }; - updateSource(); - }, [source]); - return ( - setVideoMediaObject(component)} - style={style} - videoStyle={sizeStyles} - resizeMode={mappedResizeMode} - posterStyle={[sizeStyles, { resizeMode: posterResizeMode }]} - onPlaybackStatusUpdate={onPlaybackStatusUpdate} - onFullscreenUpdate={(e) => onFullscreenUpdate(e.fullscreenUpdate)} - source={currentSource} - {...rest} - /> + + onFullscreenUpdate("entered")} + onFullscreenExit={() => onFullscreenUpdate("exited")} + allowsFullscreen={allowsFullscreen} + {...rest} + /> + {showPoster && posterSource && ( + + + + )} + ); } ); +const styles = StyleSheet.create({ + container: { + overflow: "hidden", + }, +}); + +function mapPlayerToMediaPlayerStatus( + player: VideoPlayerType +): MediaPlayerStatus { + return { + isPlaying: player.playing, + isLoading: player.status === "loading", + isBuffering: player.status === "loading", + currentPositionMillis: player.currentTime * 1000, + durationMillis: player.duration * 1000, + bufferedDurationMillis: player.bufferedPosition * 1000, + isError: player.status === "error", + }; +} + +export function mapToMediaPlayerStatus( + status: TimeUpdateEventPayload, + player: VideoPlayerType +): MediaPlayerStatus { + return { + ...mapPlayerToMediaPlayerStatus(player), + currentPositionMillis: status.currentTime * 1000, + bufferedDurationMillis: status.bufferedPosition * 1000, + }; +} + export default VideoPlayer; diff --git a/packages/maps/jest-setup.js b/packages/maps/jest-setup.js index c2358739e..0bb6db67d 100644 --- a/packages/maps/jest-setup.js +++ b/packages/maps/jest-setup.js @@ -19,18 +19,45 @@ jest.mock("expo-asset", () => { return { ...actual, Asset, getManifestBaseUrl: () => "" }; }); -jest.mock("expo-av", () => { - return { - Audio: { - setAudioModeAsync: jest.fn(), - }, - Sound: { - onPlaybackStatusUpdate: jest.fn(), - createAsync: jest.fn(), - }, - }; -}); - jest.mock("@react-native-async-storage/async-storage", () => require("@react-native-async-storage/async-storage/jest/async-storage-mock") ); + +jest.mock("expo-audio", () => ({ + useAudioPlayer: jest.fn(() => ({ + loop: false, + volume: 1.0, + playing: false, + paused: true, + muted: false, + isLoaded: false, + isBuffering: false, + currentTime: 0, + duration: 0, + play: jest.fn(), + pause: jest.fn(), + seekTo: jest.fn(), + replace: jest.fn(), + addListener: jest.fn(() => ({ remove: jest.fn() })), + })), + setAudioModeAsync: jest.fn(() => Promise.resolve()), +})); + +jest.mock("expo-video", () => ({ + useVideoPlayer: jest.fn(() => ({ + loop: false, + muted: false, + volume: 1.0, + playbackRate: 1.0, + playing: false, + status: "idle", + currentTime: 0, + duration: 0, + bufferedPosition: 0, + play: jest.fn(), + pause: jest.fn(), + replace: jest.fn(), + addListener: jest.fn(() => ({ remove: jest.fn() })), + })), + VideoView: "VideoView", +})); diff --git a/yarn.lock b/yarn.lock index c6d31609a..872e37638 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5603,10 +5603,10 @@ expo-asset@~12.0.12: "@expo/image-utils" "^0.8.8" expo-constants "~18.0.12" -expo-av@~16.0.8: - version "16.0.8" - resolved "https://registry.yarnpkg.com/expo-av/-/expo-av-16.0.8.tgz#b1671127f3b2ecaeb9c69fc2301cf791d4504dd6" - integrity sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ== +expo-audio@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/expo-audio/-/expo-audio-1.1.1.tgz#7b9763118e321c5dfbf2771cd4a5b6790ce4fc8d" + integrity sha512-CPCpJ+0AEHdzWROc0f00Zh6e+irLSl2ALos/LPvxEeIcJw1APfBa4DuHPkL4CQCWsVe7EnUjFpdwpqsEUWcP0g== expo-camera@~17.0.10: version "17.0.10" @@ -5692,6 +5692,11 @@ expo-status-bar@~3.0.9: dependencies: react-native-is-edge-to-edge "^1.2.1" +expo-video@~3.0.16: + version "3.0.16" + resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-3.0.16.tgz#8160bd33fe2e898519d3c18a404567a30d81d4f2" + integrity sha512-H1HlxcHGomZItqisGfW3YL/G9BHtNBfVSimDJcLuyxyU87wFnV8loO9tCjuhufkfh/aTa2sW5BYAjLjg9DvnBQ== + expo@^54.0.0: version "54.0.33" resolved "https://registry.yarnpkg.com/expo/-/expo-54.0.33.tgz#f7d572857323f5a8250a9afe245a487d2ee2735f"