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={`Use ${isMediaPlayerImplementation() ? 'SimpleExoPlayer' : 'MediaPlayer'}`}
90        toggled={isMediaPlayerImplementation()}
91      />
92
93      <Player
94        style={props.style}
95        errorMessage={errorMessage}
96        isLoaded={status.isLoaded}
97        isLooping={status.isLoaded ? status.isLooping : false}
98        rate={status.isLoaded ? status.rate : 1}
99        positionMillis={status.isLoaded ? status.positionMillis : 0}
100        durationMillis={status.isLoaded ? status.durationMillis || 0 : 0}
101        shouldCorrectPitch={status.isLoaded ? status.shouldCorrectPitch : false}
102        isPlaying={status.isLoaded ? status.isPlaying : false}
103        isMuted={status.isLoaded ? status.isMuted : false}
104        volume={status.isLoaded ? status.volume : 1}
105        audioPan={status.isLoaded ? status.audioPan : 0}
106        playAsync={playAsync}
107        pauseAsync={pauseAsync}
108        replayAsync={replayAsync}
109        nextAsync={changeSource}
110        setPositionAsync={setPositionAsync}
111        setIsLoopingAsync={setIsLoopingAsync}
112        setIsMutedAsync={setIsMutedAsync}
113        setRateAsync={setRateAsync}
114        setVolume={(volume, audioPan) => video.current?.setVolumeAsync(volume, audioPan)}
115        extraButtons={[
116          () => (
117            <ResizeModeSegmentedControl key="resizeModeControl" onValueChange={setResizeMode} />
118          ),
119          {
120            iconName: 'options',
121            title: 'Native controls',
122            onPress: toggleNativeControls,
123            active: useNativeControls,
124          },
125          {
126            iconName: 'resize',
127            title: 'Open full screen',
128            onPress: openFullscreen,
129            active: false,
130          },
131        ]}
132        header={
133          <Video
134            useNativeControls={useNativeControls}
135            ref={video}
136            source={props.sources[sourceIndex]}
137            resizeMode={resizeMode}
138            onError={setError}
139            style={{ height: 300 }}
140            progressUpdateIntervalMillis={100}
141            onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
142            onFullscreenUpdate={handleFullScreenUpdate}
143          />
144        }
145      />
146    </View>
147  );
148}
149
150function ResizeModeSegmentedControl({
151  onValueChange,
152}: {
153  onValueChange: (value: ResizeMode) => void;
154}) {
155  const resizeMap: Record<string, undefined | ResizeMode> = {
156    stretch: ResizeMode.STRETCH,
157    contain: ResizeMode.CONTAIN,
158    cover: ResizeMode.COVER,
159  };
160  const [index, setIndex] = React.useState(1);
161  let control;
162  if (Platform.OS === 'ios') {
163    control = (
164      <SegmentedControl
165        values={Object.keys(resizeMap)}
166        fontStyle={{ color: Colors.tintColor }}
167        selectedIndex={index}
168        tintColor="white"
169        onChange={(event) => {
170          setIndex(event.nativeEvent.selectedSegmentIndex);
171        }}
172        onValueChange={(value) => {
173          const mappedValue = resizeMap[value];
174          if (mappedValue) {
175            onValueChange(mappedValue);
176          }
177        }}
178      />
179    );
180  } else {
181    // Segmented control looks broken in this situation outside of iOS, so use text instead
182    control = Object.keys(resizeMap).map((mode, i) => (
183      <Text
184        onPress={() => {
185          setIndex(i);
186          onValueChange(resizeMap[mode]!);
187        }}
188        key={mode}
189        style={{
190          textAlign: 'center',
191          color: Colors.tintColor,
192          fontWeight: index === i ? 'bold' : 'normal',
193          marginTop: i === 0 ? 0 : 8,
194          fontSize: 12,
195        }}>
196        {mode}
197      </Text>
198    ));
199    control = <View style={{ marginTop: -5 }}>{control}</View>;
200  }
201  return (
202    <View
203      style={{
204        alignItems: 'stretch',
205        paddingBottom: 6,
206        margin: 10,
207        justifyContent: 'flex-end',
208        flex: 1,
209      }}>
210      {control}
211      {Platform.OS === 'ios' ? (
212        <Text
213          style={{
214            textAlign: 'center',
215            fontWeight: 'bold',
216            color: Colors.tintColor,
217            marginTop: 8,
218            fontSize: 12,
219          }}>
220          Resize Mode
221        </Text>
222      ) : null}
223    </View>
224  );
225}
226