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.
- package/CHANGELOG.md +16 -0
- package/android/build.gradle +2 -2
- package/android/src/main/java/expo/modules/video/VideoExceptions.kt +3 -0
- package/android/src/main/java/expo/modules/video/VideoModule.kt +3 -1
- package/android/src/main/java/expo/modules/video/VideoView.kt +25 -5
- package/build/VideoPlayer.types.d.ts +1 -1
- package/build/VideoPlayer.types.d.ts.map +1 -1
- package/build/VideoPlayer.types.js.map +1 -1
- package/build/VideoPlayer.web.d.ts +9 -1
- package/build/VideoPlayer.web.d.ts.map +1 -1
- package/build/VideoPlayer.web.js +61 -13
- package/build/VideoPlayer.web.js.map +1 -1
- package/build/VideoView.d.ts +3 -0
- package/build/VideoView.d.ts.map +1 -1
- package/build/VideoView.js +3 -0
- package/build/VideoView.js.map +1 -1
- package/build/VideoView.types.d.ts +13 -0
- package/build/VideoView.types.d.ts.map +1 -1
- package/build/VideoView.types.js.map +1 -1
- package/build/VideoView.web.d.ts.map +1 -1
- package/build/VideoView.web.js +42 -13
- package/build/VideoView.web.js.map +1 -1
- package/ios/NowPlayingManager.swift +6 -10
- package/ios/VideoModule.swift +19 -0
- package/ios/VideoPlayer.swift +7 -1
- package/package.json +2 -2
- package/plugin/build/withExpoVideo.d.ts +5 -1
- package/plugin/build/withExpoVideo.js +21 -3
- package/plugin/src/withExpoVideo.ts +35 -3
- package/src/VideoPlayer.types.ts +1 -1
- package/src/VideoPlayer.web.tsx +72 -13
- package/src/VideoView.tsx +3 -0
- package/src/VideoView.types.ts +14 -0
- package/src/VideoView.web.tsx +49 -14
package/build/VideoView.web.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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":"AAAA,OAAO,KAAK,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,mBAAmB,EAAE,MAAM,OAAO,CAAC;AAClF,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE1C,OAAoB,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAG9D,SAAS,kBAAkB;IACzB,OAAO,OAAO,MAAM,KAAK,WAAW,CAAC,CAAC,CAAC,IAAI,MAAM,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AAC1E,CAAC;AAED,SAAS,kBAAkB,CAAC,YAAiC;IAC3D,MAAM,YAAY,GAAG,YAAY,EAAE,UAAU,EAAE,IAAI,IAAI,CAAC;IAExD,IAAI,YAAY,IAAI,YAAY,EAAE;QAChC,YAAY,CAAC,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC;QAC5B,YAAY,CAAC,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;KAChD;IACD,OAAO,YAAY,CAAC;AACtB,CAAC;AAED,SAAS,SAAS,CAAC,KAA8B;IAC/C,MAAM,eAAe,GAAG,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAClD,qIAAqI;IACrI,OAAO,eAAsC,CAAC;AAChD,CAAC;AAED,MAAM,CAAC,MAAM,SAAS,GAAG,UAAU,CAAC,CAAC,KAAgD,EAAE,GAAG,EAAE,EAAE;IAC5F,MAAM,QAAQ,GAAG,MAAM,CAA0B,IAAI,CAAC,CAAC;IACvD,MAAM,YAAY,GAAG,MAAM,CAAqC,IAAI,CAAC,CAAC;
|
|
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
|
-
|
|
150
|
+
// Metadata fetched with the video
|
|
151
|
+
let assetMetadata = try? await loadMetadata(for: currentItem)
|
|
152
152
|
|
|
153
|
-
let title = assetMetadata
|
|
153
|
+
let title = assetMetadata?.first(where: {
|
|
154
154
|
$0.commonKey == .commonKeyTitle
|
|
155
155
|
})
|
|
156
156
|
|
|
157
|
-
let artist = assetMetadata
|
|
157
|
+
let artist = assetMetadata?.first(where: {
|
|
158
158
|
$0.commonKey == .commonKeyArtist
|
|
159
159
|
})
|
|
160
160
|
|
|
161
|
-
let artwork = assetMetadata
|
|
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
|
|
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
|
package/ios/VideoModule.swift
CHANGED
|
@@ -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
|
}
|
package/ios/VideoPlayer.swift
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": "
|
|
39
|
+
"gitHead": "48f1ec9d91c5c38c7684cb98a314e443cbeb2185"
|
|
40
40
|
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
import { ConfigPlugin } from '@expo/config-plugins';
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/VideoPlayer.types.ts
CHANGED
|
@@ -129,7 +129,7 @@ export type VideoPlayerEvents = {
|
|
|
129
129
|
statusChange(
|
|
130
130
|
newStatus: VideoPlayerStatus,
|
|
131
131
|
oldStatus: VideoPlayerStatus,
|
|
132
|
-
error
|
|
132
|
+
error?: PlayerError
|
|
133
133
|
): void;
|
|
134
134
|
/**
|
|
135
135
|
* Handler for an event emitted when the player starts or stops playback.
|
package/src/VideoPlayer.web.tsx
CHANGED
|
@@ -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
|
|
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.
|
|
325
|
+
this._error = {
|
|
326
|
+
message: video.error?.message ?? 'Unknown player error',
|
|
327
|
+
};
|
|
328
|
+
this.status = 'error';
|
|
280
329
|
};
|
|
281
330
|
|
|
282
|
-
video.
|
|
283
|
-
this.
|
|
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
|
-
|
|
286
|
-
video.play();
|
|
287
|
-
}
|
|
337
|
+
this.status = 'readyToPlay';
|
|
288
338
|
};
|
|
289
339
|
|
|
290
340
|
video.onwaiting = () => {
|
|
291
|
-
this._status
|
|
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
|
*/
|
package/src/VideoView.types.ts
CHANGED
|
@@ -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
|
}
|