expo-video 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,7 @@ function mapStyles(style) {
20
20
  export const VideoView = forwardRef((props, ref) => {
21
21
  const videoRef = useRef(null);
22
22
  const mediaNodeRef = useRef(null);
23
+ const hasToSetupAudioContext = useRef(false);
23
24
  /**
24
25
  * Audio context is used to mute all but one video when multiple video views are playing from one player simultaneously.
25
26
  * Using audio context nodes allows muting videos without displaying the mute icon in the video player.
@@ -39,42 +40,70 @@ export const VideoView = forwardRef((props, ref) => {
39
40
  document.exitFullscreen();
40
41
  },
41
42
  }));
42
- useEffect(() => {
43
+ // Adds the video view as a candidate for being the audio source for the player (when multiple views play from one
44
+ // player only one will emit audio).
45
+ function attachAudioNodes() {
43
46
  const audioContext = audioContextRef.current;
44
47
  const zeroGainNode = zeroGainNodeRef.current;
45
48
  const mediaNode = mediaNodeRef.current;
46
- if (videoRef.current) {
47
- props.player?.mountVideoView(videoRef.current);
48
- }
49
49
  if (audioContext && zeroGainNode && mediaNode) {
50
50
  props.player.mountAudioNode(audioContext, zeroGainNode, mediaNode);
51
51
  }
52
52
  else {
53
53
  console.warn("Couldn't mount audio node, this might affect the audio playback when using multiple video views with the same player.");
54
54
  }
55
+ }
56
+ function detachAudioNodes() {
57
+ const audioContext = audioContextRef.current;
58
+ const mediaNode = mediaNodeRef.current;
59
+ if (audioContext && mediaNode && videoRef.current) {
60
+ props.player.unmountAudioNode(videoRef.current, audioContext, mediaNode);
61
+ }
62
+ }
63
+ function maybeSetupAudioContext() {
64
+ if (!hasToSetupAudioContext.current ||
65
+ !navigator.userActivation.hasBeenActive ||
66
+ !videoRef.current) {
67
+ return;
68
+ }
69
+ const audioContext = createAudioContext();
70
+ detachAudioNodes();
71
+ audioContextRef.current = audioContext;
72
+ zeroGainNodeRef.current = createZeroGainNode(audioContextRef.current);
73
+ mediaNodeRef.current = audioContext
74
+ ? audioContext.createMediaElementSource(videoRef.current)
75
+ : null;
76
+ attachAudioNodes();
77
+ hasToSetupAudioContext.current = false;
78
+ }
79
+ useEffect(() => {
80
+ if (videoRef.current) {
81
+ props.player?.mountVideoView(videoRef.current);
82
+ }
83
+ attachAudioNodes();
55
84
  return () => {
56
85
  if (videoRef.current) {
57
86
  props.player?.unmountVideoView(videoRef.current);
58
87
  }
59
- if (videoRef.current && audioContext && mediaNode) {
60
- props.player?.unmountAudioNode(videoRef.current, audioContext, mediaNode);
61
- }
88
+ detachAudioNodes();
62
89
  };
63
90
  }, [props.player]);
64
91
  return (<video controls={props.nativeControls ?? true} controlsList={props.allowsFullscreen ? undefined : 'nofullscreen'} crossOrigin="anonymous" style={{
65
92
  ...mapStyles(props.style),
66
93
  objectFit: props.contentFit,
94
+ }} onPlay={() => {
95
+ maybeSetupAudioContext();
96
+ }}
97
+ // The player can autoplay when muted, unmuting by a user should create the audio context
98
+ onVolumeChange={() => {
99
+ maybeSetupAudioContext();
67
100
  }} ref={(newRef) => {
68
101
  // This is called with a null value before `player.unmountVideoView` is called,
69
102
  // we can't assign null to videoRef if we want to unmount it from the player.
70
103
  if (newRef && !newRef.isEqualNode(videoRef.current)) {
71
104
  videoRef.current = newRef;
72
- const audioContext = createAudioContext();
73
- audioContextRef.current = audioContext;
74
- zeroGainNodeRef.current = createZeroGainNode(audioContextRef.current);
75
- mediaNodeRef.current = audioContext
76
- ? audioContext.createMediaElementSource(newRef)
77
- : null;
105
+ hasToSetupAudioContext.current = true;
106
+ maybeSetupAudioContext();
78
107
  }
79
108
  }} src={getSourceUri(props.player?.src) ?? ''}/>);
80
109
  });
@@ -1 +1 @@
1
- {"version":3,"file":"VideoView.web.js","sourceRoot":"","sources":["../src/VideoView.web.tsx"],"names":[],"mappings":"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","sourcesContent":["import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';\nimport { StyleSheet } from 'react-native';\n\nimport VideoPlayer, { getSourceUri } from './VideoPlayer.web';\nimport type { VideoViewProps } from './VideoView.types';\n\nfunction createAudioContext(): AudioContext | null {\n return typeof window !== 'undefined' ? new window.AudioContext() : null;\n}\n\nfunction createZeroGainNode(audioContext: AudioContext | null): GainNode | null {\n const zeroGainNode = audioContext?.createGain() ?? null;\n\n if (audioContext && zeroGainNode) {\n zeroGainNode.gain.value = 0;\n zeroGainNode.connect(audioContext.destination);\n }\n return zeroGainNode;\n}\n\nfunction mapStyles(style: VideoViewProps['style']): React.CSSProperties {\n const flattenedStyles = StyleSheet.flatten(style);\n // Looking through react-native-web source code they also just pass styles directly without further conversions, so it's just a cast.\n return flattenedStyles as React.CSSProperties;\n}\n\nexport const VideoView = forwardRef((props: { player?: VideoPlayer } & VideoViewProps, ref) => {\n const videoRef = useRef<null | HTMLVideoElement>(null);\n const mediaNodeRef = useRef<null | MediaElementAudioSourceNode>(null);\n\n /**\n * Audio context is used to mute all but one video when multiple video views are playing from one player simultaneously.\n * Using audio context nodes allows muting videos without displaying the mute icon in the video player.\n * We have to keep the context that called createMediaElementSource(videoRef), as the method can't be called\n * for the second time with another context and there is no way to unbind the video and audio context afterward.\n */\n const audioContextRef = useRef<null | AudioContext>(null);\n const zeroGainNodeRef = useRef<null | GainNode>(null);\n\n useImperativeHandle(ref, () => ({\n enterFullscreen: () => {\n if (!props.allowsFullscreen) {\n return;\n }\n videoRef.current?.requestFullscreen();\n },\n exitFullscreen: () => {\n document.exitFullscreen();\n },\n }));\n\n useEffect(() => {\n const audioContext = audioContextRef.current;\n const zeroGainNode = zeroGainNodeRef.current;\n const mediaNode = mediaNodeRef.current;\n\n if (videoRef.current) {\n props.player?.mountVideoView(videoRef.current);\n }\n\n if (audioContext && zeroGainNode && mediaNode) {\n props.player.mountAudioNode(audioContext, zeroGainNode, mediaNode);\n } else {\n console.warn(\n \"Couldn't mount audio node, this might affect the audio playback when using multiple video views with the same player.\"\n );\n }\n\n return () => {\n if (videoRef.current) {\n props.player?.unmountVideoView(videoRef.current);\n }\n if (videoRef.current && audioContext && mediaNode) {\n props.player?.unmountAudioNode(videoRef.current, audioContext, mediaNode);\n }\n };\n }, [props.player]);\n\n return (\n <video\n controls={props.nativeControls ?? true}\n controlsList={props.allowsFullscreen ? undefined : 'nofullscreen'}\n crossOrigin=\"anonymous\"\n style={{\n ...mapStyles(props.style),\n objectFit: props.contentFit,\n }}\n ref={(newRef) => {\n // This is called with a null value before `player.unmountVideoView` is called,\n // we can't assign null to videoRef if we want to unmount it from the player.\n if (newRef && !newRef.isEqualNode(videoRef.current)) {\n videoRef.current = newRef;\n const audioContext = createAudioContext();\n audioContextRef.current = audioContext;\n zeroGainNodeRef.current = createZeroGainNode(audioContextRef.current);\n mediaNodeRef.current = audioContext\n ? audioContext.createMediaElementSource(newRef)\n : null;\n }\n }}\n src={getSourceUri(props.player?.src) ?? ''}\n />\n );\n});\n\nexport default VideoView;\n"]}
1
+ {"version":3,"file":"VideoView.web.js","sourceRoot":"","sources":["../src/VideoView.web.tsx"],"names":[],"mappings":"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","sourcesContent":["import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';\nimport { StyleSheet } from 'react-native';\n\nimport VideoPlayer, { getSourceUri } from './VideoPlayer.web';\nimport type { VideoViewProps } from './VideoView.types';\n\nfunction createAudioContext(): AudioContext | null {\n return typeof window !== 'undefined' ? new window.AudioContext() : null;\n}\n\nfunction createZeroGainNode(audioContext: AudioContext | null): GainNode | null {\n const zeroGainNode = audioContext?.createGain() ?? null;\n\n if (audioContext && zeroGainNode) {\n zeroGainNode.gain.value = 0;\n zeroGainNode.connect(audioContext.destination);\n }\n return zeroGainNode;\n}\n\nfunction mapStyles(style: VideoViewProps['style']): React.CSSProperties {\n const flattenedStyles = StyleSheet.flatten(style);\n // Looking through react-native-web source code they also just pass styles directly without further conversions, so it's just a cast.\n return flattenedStyles as React.CSSProperties;\n}\n\nexport const VideoView = forwardRef((props: { player?: VideoPlayer } & VideoViewProps, ref) => {\n const videoRef = useRef<null | HTMLVideoElement>(null);\n const mediaNodeRef = useRef<null | MediaElementAudioSourceNode>(null);\n const hasToSetupAudioContext = useRef(false);\n\n /**\n * Audio context is used to mute all but one video when multiple video views are playing from one player simultaneously.\n * Using audio context nodes allows muting videos without displaying the mute icon in the video player.\n * We have to keep the context that called createMediaElementSource(videoRef), as the method can't be called\n * for the second time with another context and there is no way to unbind the video and audio context afterward.\n */\n const audioContextRef = useRef<null | AudioContext>(null);\n const zeroGainNodeRef = useRef<null | GainNode>(null);\n\n useImperativeHandle(ref, () => ({\n enterFullscreen: () => {\n if (!props.allowsFullscreen) {\n return;\n }\n videoRef.current?.requestFullscreen();\n },\n exitFullscreen: () => {\n document.exitFullscreen();\n },\n }));\n\n // Adds the video view as a candidate for being the audio source for the player (when multiple views play from one\n // player only one will emit audio).\n function attachAudioNodes() {\n const audioContext = audioContextRef.current;\n const zeroGainNode = zeroGainNodeRef.current;\n const mediaNode = mediaNodeRef.current;\n\n if (audioContext && zeroGainNode && mediaNode) {\n props.player.mountAudioNode(audioContext, zeroGainNode, mediaNode);\n } else {\n console.warn(\n \"Couldn't mount audio node, this might affect the audio playback when using multiple video views with the same player.\"\n );\n }\n }\n\n function detachAudioNodes() {\n const audioContext = audioContextRef.current;\n const mediaNode = mediaNodeRef.current;\n if (audioContext && mediaNode && videoRef.current) {\n props.player.unmountAudioNode(videoRef.current, audioContext, mediaNode);\n }\n }\n\n function maybeSetupAudioContext() {\n if (\n !hasToSetupAudioContext.current ||\n !navigator.userActivation.hasBeenActive ||\n !videoRef.current\n ) {\n return;\n }\n const audioContext = createAudioContext();\n\n detachAudioNodes();\n audioContextRef.current = audioContext;\n zeroGainNodeRef.current = createZeroGainNode(audioContextRef.current);\n mediaNodeRef.current = audioContext\n ? audioContext.createMediaElementSource(videoRef.current)\n : null;\n attachAudioNodes();\n hasToSetupAudioContext.current = false;\n }\n\n useEffect(() => {\n if (videoRef.current) {\n props.player?.mountVideoView(videoRef.current);\n }\n attachAudioNodes();\n\n return () => {\n if (videoRef.current) {\n props.player?.unmountVideoView(videoRef.current);\n }\n detachAudioNodes();\n };\n }, [props.player]);\n\n return (\n <video\n controls={props.nativeControls ?? true}\n controlsList={props.allowsFullscreen ? undefined : 'nofullscreen'}\n crossOrigin=\"anonymous\"\n style={{\n ...mapStyles(props.style),\n objectFit: props.contentFit,\n }}\n onPlay={() => {\n maybeSetupAudioContext();\n }}\n // The player can autoplay when muted, unmuting by a user should create the audio context\n onVolumeChange={() => {\n maybeSetupAudioContext();\n }}\n ref={(newRef) => {\n // This is called with a null value before `player.unmountVideoView` is called,\n // we can't assign null to videoRef if we want to unmount it from the player.\n if (newRef && !newRef.isEqualNode(videoRef.current)) {\n videoRef.current = newRef;\n hasToSetupAudioContext.current = true;\n maybeSetupAudioContext();\n }\n }}\n src={getSourceUri(props.player?.src) ?? ''}\n />\n );\n});\n\nexport default VideoView;\n"]}
@@ -13,7 +13,6 @@ class NowPlayingManager: VideoPlayerObserverDelegate {
13
13
  static var shared = NowPlayingManager()
14
14
 
15
15
  private let skipTimeInterval = 10.0
16
- private let fetchMetadataQueue = DispatchQueue(label: "com.expo.fetchMetadataQueue")
17
16
  private var timeObserver: Any?
18
17
  private weak var mostRecentInteractionPlayer: AVPlayer?
19
18
  private var players = NSHashTable<VideoPlayer>.weakObjects()
@@ -148,17 +147,18 @@ class NowPlayingManager: VideoPlayerObserverDelegate {
148
147
  let userMetadata = videoPlayerItem?.videoSource.metadata
149
148
 
150
149
  Task {
151
- let assetMetadata = await try loadMetadata(for: currentItem)
150
+ // Metadata fetched with the video
151
+ let assetMetadata = try? await loadMetadata(for: currentItem)
152
152
 
153
- let title = assetMetadata.first(where: {
153
+ let title = assetMetadata?.first(where: {
154
154
  $0.commonKey == .commonKeyTitle
155
155
  })
156
156
 
157
- let artist = assetMetadata.first(where: {
157
+ let artist = assetMetadata?.first(where: {
158
158
  $0.commonKey == .commonKeyArtist
159
159
  })
160
160
 
161
- let artwork = assetMetadata.first(where: {
161
+ let artwork = assetMetadata?.first(where: {
162
162
  $0.commonKey == .commonKeyArtwork
163
163
  })
164
164
 
@@ -181,11 +181,7 @@ class NowPlayingManager: VideoPlayerObserverDelegate {
181
181
  return try await mediaItem.asset.loadMetadata(for: .iTunesMetadata)
182
182
  }
183
183
 
184
- return await withCheckedContinuation { continuation in
185
- fetchMetadataQueue.async {
186
- continuation.resume(returning: mediaItem.asset.metadata)
187
- }
188
- }
184
+ return mediaItem.asset.metadata
189
185
  }
190
186
 
191
187
  // Updates nowPlaying information that changes dynamically during playback e.g. progress
@@ -72,6 +72,25 @@ public final class VideoModule: Module {
72
72
  #endif
73
73
  }
74
74
 
75
+ Prop("allowsVideoFrameAnalysis") { (view, allowsVideoFrameAnalysis: Bool?) in
76
+ #if !os(tvOS)
77
+ if #available(iOS 16.0, macCatalyst 18.0, *) {
78
+ let newValue = allowsVideoFrameAnalysis ?? true
79
+
80
+ view.playerViewController.allowsVideoFrameAnalysis = newValue
81
+
82
+ // Setting the `allowsVideoFrameAnalysis` to false after the scanning was already perofrmed doesn't update the UI.
83
+ // We can force the desired behaviour by quickly toggling the property. Setting it to true clears existing requests,
84
+ // which updates the UI, hiding the button, then setting it to false before it detects any text keeps it in the desired state.
85
+ // Tested in iOS 17.5
86
+ if !newValue {
87
+ view.playerViewController.allowsVideoFrameAnalysis = true
88
+ view.playerViewController.allowsVideoFrameAnalysis = false
89
+ }
90
+ }
91
+ #endif
92
+ }
93
+
75
94
  AsyncFunction("enterFullscreen") { view in
76
95
  view.enterFullscreen()
77
96
  }
@@ -86,7 +86,13 @@ internal final class VideoPlayer: SharedRef<AVPlayer>, Hashable, VideoPlayerObse
86
86
  observer.cleanup()
87
87
  NowPlayingManager.shared.unregisterPlayer(self)
88
88
  VideoManager.shared.unregister(videoPlayer: self)
89
- pointer.replaceCurrentItem(with: nil)
89
+
90
+ // The current item has to be replaced with nil from the main thread. When replacing from the SharedObjectRegistry queue
91
+ // sometimes the KVOs used by AVPlayerViewController would try to deliver updates about the item being changed to nil after the
92
+ // player was deallocated, which caused crashes.
93
+ DispatchQueue.main.async { [pointer] in
94
+ pointer.replaceCurrentItem(with: nil)
95
+ }
90
96
  }
91
97
 
92
98
  func replaceCurrentItem(with videoSource: VideoSource?) throws {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "expo-video",
3
3
  "title": "Expo Video",
4
- "version": "1.2.2",
4
+ "version": "1.2.3",
5
5
  "description": "A cross-platform, performant video component for React Native and Expo with Web support",
6
6
  "main": "build/index.js",
7
7
  "types": "build/index.d.ts",
@@ -36,5 +36,5 @@
36
36
  "peerDependencies": {
37
37
  "expo": "*"
38
38
  },
39
- "gitHead": "c37efa4f4ebe6137c34eb5813843df7015f3c22f"
39
+ "gitHead": "48f1ec9d91c5c38c7684cb98a314e443cbeb2185"
40
40
  }
@@ -1,3 +1,7 @@
1
1
  import { ConfigPlugin } from '@expo/config-plugins';
2
- declare const withExpoVideo: ConfigPlugin;
2
+ type WithExpoVideoOptions = {
3
+ supportsBackgroundPlayback?: boolean;
4
+ supportsPictureInPicture?: boolean;
5
+ };
6
+ declare const withExpoVideo: ConfigPlugin<WithExpoVideoOptions>;
3
7
  export default withExpoVideo;
@@ -1,17 +1,35 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const config_plugins_1 = require("@expo/config-plugins");
4
- const withExpoVideo = (config) => {
4
+ const withExpoVideo = (config, { supportsBackgroundPlayback, supportsPictureInPicture } = {}) => {
5
5
  (0, config_plugins_1.withInfoPlist)(config, (config) => {
6
6
  const currentBackgroundModes = config.modResults.UIBackgroundModes ?? [];
7
- if (!currentBackgroundModes.includes('audio')) {
7
+ const shouldEnableBackgroundAudio = supportsBackgroundPlayback || supportsPictureInPicture;
8
+ // No-op if the values are not defined
9
+ if (typeof supportsBackgroundPlayback === 'undefined' &&
10
+ typeof supportsPictureInPicture === 'undefined') {
11
+ return config;
12
+ }
13
+ if (shouldEnableBackgroundAudio && !currentBackgroundModes.includes('audio')) {
8
14
  config.modResults.UIBackgroundModes = [...currentBackgroundModes, 'audio'];
9
15
  }
16
+ else if (!shouldEnableBackgroundAudio) {
17
+ config.modResults.UIBackgroundModes = currentBackgroundModes.filter((mode) => mode !== 'audio');
18
+ }
10
19
  return config;
11
20
  });
12
21
  (0, config_plugins_1.withAndroidManifest)(config, (config) => {
13
22
  const activity = config_plugins_1.AndroidConfig.Manifest.getMainActivityOrThrow(config.modResults);
14
- activity.$['android:supportsPictureInPicture'] = 'true';
23
+ // No-op if the values are not defined
24
+ if (typeof supportsPictureInPicture === 'undefined') {
25
+ return config;
26
+ }
27
+ if (supportsPictureInPicture) {
28
+ activity.$['android:supportsPictureInPicture'] = 'true';
29
+ }
30
+ else {
31
+ delete activity.$['android:supportsPictureInPicture'];
32
+ }
15
33
  return config;
16
34
  });
17
35
  return config;
@@ -5,18 +5,50 @@ import {
5
5
  withAndroidManifest,
6
6
  } from '@expo/config-plugins';
7
7
 
8
- const withExpoVideo: ConfigPlugin = (config) => {
8
+ type WithExpoVideoOptions = {
9
+ supportsBackgroundPlayback?: boolean;
10
+ supportsPictureInPicture?: boolean;
11
+ };
12
+
13
+ const withExpoVideo: ConfigPlugin<WithExpoVideoOptions> = (
14
+ config,
15
+ { supportsBackgroundPlayback, supportsPictureInPicture } = {}
16
+ ) => {
9
17
  withInfoPlist(config, (config) => {
10
18
  const currentBackgroundModes = config.modResults.UIBackgroundModes ?? [];
11
- if (!currentBackgroundModes.includes('audio')) {
19
+ const shouldEnableBackgroundAudio = supportsBackgroundPlayback || supportsPictureInPicture;
20
+
21
+ // No-op if the values are not defined
22
+ if (
23
+ typeof supportsBackgroundPlayback === 'undefined' &&
24
+ typeof supportsPictureInPicture === 'undefined'
25
+ ) {
26
+ return config;
27
+ }
28
+
29
+ if (shouldEnableBackgroundAudio && !currentBackgroundModes.includes('audio')) {
12
30
  config.modResults.UIBackgroundModes = [...currentBackgroundModes, 'audio'];
31
+ } else if (!shouldEnableBackgroundAudio) {
32
+ config.modResults.UIBackgroundModes = currentBackgroundModes.filter(
33
+ (mode: string) => mode !== 'audio'
34
+ );
13
35
  }
14
36
  return config;
15
37
  });
16
38
 
17
39
  withAndroidManifest(config, (config) => {
18
40
  const activity = AndroidConfig.Manifest.getMainActivityOrThrow(config.modResults);
19
- activity.$['android:supportsPictureInPicture'] = 'true';
41
+
42
+ // No-op if the values are not defined
43
+ if (typeof supportsPictureInPicture === 'undefined') {
44
+ return config;
45
+ }
46
+
47
+ if (supportsPictureInPicture) {
48
+ activity.$['android:supportsPictureInPicture'] = 'true';
49
+ } else {
50
+ delete activity.$['android:supportsPictureInPicture'];
51
+ }
20
52
  return config;
21
53
  });
22
54
  return config;
@@ -129,7 +129,7 @@ export type VideoPlayerEvents = {
129
129
  statusChange(
130
130
  newStatus: VideoPlayerStatus,
131
131
  oldStatus: VideoPlayerStatus,
132
- error: PlayerError
132
+ error?: PlayerError
133
133
  ): void;
134
134
  /**
135
135
  * Handler for an event emitted when the player starts or stops playback.
@@ -1,6 +1,7 @@
1
1
  import { useMemo } from 'react';
2
2
 
3
3
  import type {
4
+ PlayerError,
4
5
  VideoPlayer,
5
6
  VideoPlayerEvents,
6
7
  VideoPlayerStatus,
@@ -37,6 +38,7 @@ export default class VideoPlayerWeb
37
38
  }
38
39
 
39
40
  src: VideoSource = null;
41
+ previousSrc: VideoSource = null;
40
42
  _mountedVideos: Set<HTMLVideoElement> = new Set();
41
43
  _audioNodes: Set<MediaElementAudioSourceNode> = new Set();
42
44
  playing: boolean = false;
@@ -46,6 +48,7 @@ export default class VideoPlayerWeb
46
48
  _playbackRate: number = 1.0;
47
49
  _preservesPitch: boolean = true;
48
50
  _status: VideoPlayerStatus = 'idle';
51
+ _error: PlayerError | null = null;
49
52
  staysActiveInBackground: boolean = false; // Not supported on web. Dummy to match the interface.
50
53
  showNowPlayingNotification: boolean = false; // Not supported on web. Dummy to match the interface.
51
54
 
@@ -82,9 +85,6 @@ export default class VideoPlayerWeb
82
85
  }
83
86
 
84
87
  get volume(): number {
85
- this._mountedVideos.forEach((video) => {
86
- this._volume = video.volume;
87
- });
88
88
  return this._volume;
89
89
  }
90
90
 
@@ -130,7 +130,27 @@ export default class VideoPlayerWeb
130
130
  return this._status;
131
131
  }
132
132
 
133
+ private set status(value: VideoPlayerStatus) {
134
+ if (this._status === value) return;
135
+
136
+ if (value === 'error' && this._error) {
137
+ this.emit('statusChange', value, this._status, this._error);
138
+ } else {
139
+ this.emit('statusChange', value, this._status);
140
+ this._error = null;
141
+ }
142
+ this._status = value;
143
+ }
144
+
133
145
  mountVideoView(video: HTMLVideoElement) {
146
+ // The video will be the first video, it should inherit the properties set in the setup() function
147
+ if (this._mountedVideos.size === 0) {
148
+ video.preservesPitch = this._preservesPitch;
149
+ video.loop = this._loop;
150
+ video.volume = this._volume;
151
+ video.muted = this._muted;
152
+ video.playbackRate = this._playbackRate;
153
+ }
134
154
  this._mountedVideos.add(video);
135
155
  this._addListeners(video);
136
156
  this._synchronizeWithFirstVideo(video);
@@ -178,14 +198,12 @@ export default class VideoPlayerWeb
178
198
  this._mountedVideos.forEach((video) => {
179
199
  video.play();
180
200
  });
181
- this.playing = true;
182
201
  }
183
202
 
184
203
  pause(): void {
185
204
  this._mountedVideos.forEach((video) => {
186
205
  video.pause();
187
206
  });
188
- this.playing = false;
189
207
  }
190
208
 
191
209
  replace(source: VideoSource): void {
@@ -201,6 +219,9 @@ export default class VideoPlayerWeb
201
219
  video.load();
202
220
  }
203
221
  });
222
+ // TODO @behenate: this won't work when we add support for playlists
223
+ this.previousSrc = this.src;
224
+ this.src = source;
204
225
  this.playing = true;
205
226
  }
206
227
 
@@ -233,8 +254,24 @@ export default class VideoPlayerWeb
233
254
  video.playbackRate = firstVideo.playbackRate;
234
255
  }
235
256
 
257
+ /**
258
+ * If there are multiple mounted videos, all of them will emit an event, as they are synchronised.
259
+ * We want to avoid this, so we only emit the event if it came from the first video.
260
+ */
261
+ _emitOnce<EventName extends keyof VideoPlayerEvents>(
262
+ eventSource: HTMLVideoElement,
263
+ eventName: EventName,
264
+ ...args: Parameters<VideoPlayerEvents[EventName]>
265
+ ): void {
266
+ const mountedVideos = [...this._mountedVideos];
267
+ if (mountedVideos[0] === eventSource) {
268
+ this.emit(eventName, ...args);
269
+ }
270
+ }
271
+
236
272
  _addListeners(video: HTMLVideoElement): void {
237
273
  video.onplay = () => {
274
+ this._emitOnce(video, 'playingChange', true, this.playing);
238
275
  this.playing = true;
239
276
  this._mountedVideos.forEach((mountedVideo) => {
240
277
  mountedVideo.play();
@@ -242,6 +279,7 @@ export default class VideoPlayerWeb
242
279
  };
243
280
 
244
281
  video.onpause = () => {
282
+ this._emitOnce(video, 'playingChange', false, this.playing);
245
283
  this.playing = false;
246
284
  this._mountedVideos.forEach((mountedVideo) => {
247
285
  mountedVideo.pause();
@@ -249,6 +287,12 @@ export default class VideoPlayerWeb
249
287
  };
250
288
 
251
289
  video.onvolumechange = () => {
290
+ this._emitOnce(
291
+ video,
292
+ 'volumeChange',
293
+ { volume: video.volume, isMuted: video.muted },
294
+ { volume: this.volume, isMuted: this.muted }
295
+ );
252
296
  this.volume = video.volume;
253
297
  this.muted = video.muted;
254
298
  };
@@ -268,27 +312,42 @@ export default class VideoPlayerWeb
268
312
  };
269
313
 
270
314
  video.onratechange = () => {
315
+ this._emitOnce(video, 'playbackRateChange', video.playbackRate, this.playbackRate);
271
316
  this._mountedVideos.forEach((mountedVideo) => {
272
- if (mountedVideo === video || mountedVideo.playbackRate === video.playbackRate) return;
317
+ if (mountedVideo.playbackRate === video.playbackRate) return;
273
318
  this._playbackRate = video.playbackRate;
274
319
  mountedVideo.playbackRate = video.playbackRate;
275
320
  });
321
+ this._playbackRate = video.playbackRate;
276
322
  };
277
323
 
278
324
  video.onerror = () => {
279
- this._status = 'error';
325
+ this._error = {
326
+ message: video.error?.message ?? 'Unknown player error',
327
+ };
328
+ this.status = 'error';
280
329
  };
281
330
 
282
- video.onloadeddata = () => {
283
- this._status = 'readyToPlay';
331
+ video.oncanplay = () => {
332
+ const allCanPlay = [...this._mountedVideos].reduce((previousValue, video) => {
333
+ return previousValue && video.readyState >= 3;
334
+ }, true);
335
+ if (!allCanPlay) return;
284
336
 
285
- if (this.playing && video.paused) {
286
- video.play();
287
- }
337
+ this.status = 'readyToPlay';
288
338
  };
289
339
 
290
340
  video.onwaiting = () => {
291
- this._status = 'loading';
341
+ if (this._status === 'loading') return;
342
+ this.status = 'loading';
343
+ };
344
+
345
+ video.onended = () => {
346
+ this._emitOnce(video, 'playToEnd');
347
+ };
348
+
349
+ video.onloadstart = () => {
350
+ this._emitOnce(video, 'sourceChange', this.src, this.previousSrc);
292
351
  };
293
352
  }
294
353
  }
package/src/VideoView.tsx CHANGED
@@ -35,6 +35,9 @@ export class VideoView extends PureComponent<VideoViewProps> {
35
35
  /**
36
36
  * Enters Picture in Picture (PiP) mode. Throws an exception if the device does not support PiP.
37
37
  * > **Note:** Only one player can be in Picture in Picture (PiP) mode at a time.
38
+ *
39
+ * > **Note:** The `supportsPictureInPicture` property of the [config plugin](#configuration-in-appjsonappconfigjs)
40
+ * > has to be configured for the PiP to work.
38
41
  * @platform android
39
42
  * @platform ios 14+
40
43
  */
@@ -73,6 +73,9 @@ export interface VideoViewProps extends ViewProps {
73
73
 
74
74
  /**
75
75
  * Determines whether the player allows Picture in Picture (PiP) mode.
76
+ * > **Note:** The `supportsPictureInPicture` property of the [config plugin](#configuration-in-appjsonappconfigjs)
77
+ * > has to be configured for the PiP to work.
78
+ *
76
79
  * @default false
77
80
  * @platform ios 14+
78
81
  */
@@ -81,9 +84,20 @@ export interface VideoViewProps extends ViewProps {
81
84
  /**
82
85
  * Determines whether the player should start Picture in Picture (PiP) automatically when the app is in the background.
83
86
  * > **Note:** Only one player can be in Picture in Picture (PiP) mode at a time.
87
+ *
88
+ * > **Note:** The `supportsPictureInPicture` property of the [config plugin](#configuration-in-appjsonappconfigjs)
89
+ * > has to be configured for the PiP to work.
90
+ *
84
91
  * @default false
85
92
  * @platform android 12+
86
93
  * @platform ios 14.2+
87
94
  */
88
95
  startsPictureInPictureAutomatically?: boolean;
96
+
97
+ /**
98
+ * Specifies whether to perform video frame analysis (Live Text in videos). Check official [Apple documentation](https://developer.apple.com/documentation/avkit/avplayerviewcontroller/allowsvideoframeanalysis) for more details.
99
+ * @default true
100
+ * @platform ios 16.0+
101
+ */
102
+ allowsVideoFrameAnalysis?: boolean;
89
103
  }