1import SegmentedControl from '@react-native-segmented-control/segmented-control';
2import { diff } from 'deep-object-diff';
3import { Asset } from 'expo-asset';
4import { AVPlaybackStatus, ResizeMode, Video, VideoFullscreenUpdateEvent } from 'expo-av';
5import React from 'react';
6import { Platform, StyleProp, Text, View, ViewStyle } from 'react-native';
7
8import { Colors } from '../../constants';
9import { AndroidImplementationSelector } from './AndroidImplementationSelector';
10import Player from './Player';
11
12type VideoPlayerSource =
13  | number
14  | {
15      uri: string;
16      overrideFileExtensionAndroid?: string;
17      headers?: {
18        [fieldName: string]: string;
19      };
20    }
21  | Asset;
22
23export default function VideoPlayer(props: {
24  style?: StyleProp<ViewStyle>;
25  sources: VideoPlayerSource[];
26}) {
27  const [sourceIndex, setIndex] = React.useState(0);
28  const [errorMessage, setError] = React.useState<undefined | string>(undefined);
29  const [useNativeControls, setUseNativeControls] = React.useState(false);
30  const [resizeMode, setResizeMode] = React.useState<ResizeMode>(ResizeMode.CONTAIN);
31  const prevStatus = React.useRef<AVPlaybackStatus | null>(null);
32
33  const [status, setStatus] = React.useState<AVPlaybackStatus>({
34    isLoaded: false,
35  });
36
37  const video = React.useRef<Video>(null);
38
39  const handlePlaybackStatusUpdate = (status: AVPlaybackStatus) => {
40    console.log('onPlaybackStatusUpdate: ', diff(prevStatus.current || {}, status));
41    prevStatus.current = status;
42    setStatus(status);
43  };
44
45  const handleFullScreenUpdate = (event: VideoFullscreenUpdateEvent) =>
46    console.log('onFullscreenUpdate', event);
47
48  const playAsync = async () => video.current?.playAsync();
49
50  const pauseAsync = async () => video.current?.pauseAsync();
51
52  const replayAsync = async () => video.current?.replayAsync();
53
54  const setPositionAsync = async (position: number) => video.current?.setPositionAsync(position);
55
56  const setIsLoopingAsync = async (isLooping: boolean) =>
57    video.current?.setIsLoopingAsync(isLooping);
58
59  const setIsMutedAsync = async (isMuted: boolean) => video.current?.setIsMutedAsync(isMuted);
60
61  const setRateAsync = async (rate: number, shouldCorrectPitch: boolean) =>
62    video.current?.setRateAsync(rate, shouldCorrectPitch);
63
64  const toggleNativeControls = () =>
65    setUseNativeControls((useNativeControls) => !useNativeControls);
66
67  const openFullscreen = () => video.current?.presentFullscreenPlayer();
68
69  const changeSource = () => {
70    setIndex((index) => (index + 1) % props.sources.length);
71  };
72  const isMediaPlayerImplementation = () => status.androidImplementation === 'MediaPlayer';
73
74  const toggleAndroidImplementation = async () => {
75    if (status.isLoaded) {
76      if (status.isPlaying) {
77        await video.current?.pauseAsync();
78      }
79      await video.current?.unloadAsync();
80    }
81    await video.current?.loadAsync(props.sources[sourceIndex], {
82      androidImplementation: isMediaPlayerImplementation() ? 'SimpleExoPlayer' : 'MediaPlayer',
83    });
84  };
85  return (
86    <View>
87      <AndroidImplementationSelector
88        onToggle={toggleAndroidImplementation}
89        title={`Current player: ${
90          isMediaPlayerImplementation() ? 'MediaPlayer' : 'SimpleExoPlayer'
91        }`}
92        toggled={isMediaPlayerImplementation()}
93      />
94
95      <Player
96        style={props.style}
97        errorMessage={errorMessage}
98        isLoaded={status.isLoaded}
99        isLooping={status.isLoaded ? status.isLooping : false}
100        rate={status.isLoaded ? status.rate : 1}
101        positionMillis={status.isLoaded ? status.positionMillis : 0}
102        durationMillis={status.isLoaded ? status.durationMillis || 0 : 0}
103        shouldCorrectPitch={status.isLoaded ? status.shouldCorrectPitch : false}
104        isPlaying={status.isLoaded ? status.isPlaying : false}
105        isMuted={status.isLoaded ? status.isMuted : false}
106        volume={status.isLoaded ? status.volume : 1}
107        audioPan={status.isLoaded ? status.audioPan : 0}
108        playAsync={playAsync}
109        pauseAsync={pauseAsync}
110        replayAsync={replayAsync}
111        nextAsync={changeSource}
112        setPositionAsync={setPositionAsync}
113        setIsLoopingAsync={setIsLoopingAsync}
114        setIsMutedAsync={setIsMutedAsync}
115        setRateAsync={setRateAsync}
116        setVolume={(volume, audioPan) => video.current?.setVolumeAsync(volume, audioPan)}
117        extraButtons={[
118          () => (
119            <ResizeModeSegmentedControl key="resizeModeControl" onValueChange={setResizeMode} />
120          ),
121          {
122            iconName: 'options',
123            title: 'Native controls',
124            onPress: toggleNativeControls,
125            active: useNativeControls,
126          },
127          {
128            iconName: 'resize',
129            title: 'Open full screen',
130            onPress: openFullscreen,
131            active: false,
132          },
133        ]}
134        header={
135          <Video
136            useNativeControls={useNativeControls}
137            ref={video}
138            source={props.sources[sourceIndex]}
139            resizeMode={resizeMode}
140            onError={setError}
141            style={{ height: 300 }}
142            progressUpdateIntervalMillis={100}
143            onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
144            onFullscreenUpdate={handleFullScreenUpdate}
145          />
146        }
147      />
148    </View>
149  );
150}
151
152function ResizeModeSegmentedControl({
153  onValueChange,
154}: {
155  onValueChange: (value: ResizeMode) => void;
156}) {
157  const resizeMap: Record<string, undefined | ResizeMode> = {
158    stretch: ResizeMode.STRETCH,
159    contain: ResizeMode.CONTAIN,
160    cover: ResizeMode.COVER,
161  };
162  const [index, setIndex] = React.useState(1);
163  let control;
164  if (Platform.OS === 'ios') {
165    control = (
166      <SegmentedControl
167        values={Object.keys(resizeMap)}
168        fontStyle={{ color: Colors.tintColor }}
169        selectedIndex={index}
170        tintColor="white"
171        onChange={(event) => {
172          setIndex(event.nativeEvent.selectedSegmentIndex);
173        }}
174        onValueChange={(value) => {
175          const mappedValue = resizeMap[value];
176          if (mappedValue) {
177            onValueChange(mappedValue);
178          }
179        }}
180      />
181    );
182  } else {
183    // Segmented control looks broken in this situation outside of iOS, so use text instead
184    control = Object.keys(resizeMap).map((mode, i) => (
185      <Text
186        onPress={() => {
187          setIndex(i);
188          onValueChange(resizeMap[mode]!);
189        }}
190        key={mode}
191        style={{
192          textAlign: 'center',
193          color: Colors.tintColor,
194          fontWeight: index === i ? 'bold' : 'normal',
195          marginTop: i === 0 ? 0 : 8,
196          fontSize: 12,
197        }}>
198        {mode}
199      </Text>
200    ));
201    control = <View style={{ marginTop: -5 }}>{control}</View>;
202  }
203  return (
204    <View
205      style={{
206        alignItems: 'stretch',
207        paddingBottom: 6,
208        margin: 10,
209        justifyContent: 'flex-end',
210        flex: 1,
211      }}>
212      {control}
213      {Platform.OS === 'ios' ? (
214        <Text
215          style={{
216            textAlign: 'center',
217            fontWeight: 'bold',
218            color: Colors.tintColor,
219            marginTop: 8,
220            fontSize: 12,
221          }}>
222          Resize Mode
223        </Text>
224      ) : null}
225    </View>
226  );
227}
228