@streamplace/components 0.7.15 → 0.7.18
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/package.json +2 -2
- package/src/components/chat/chat.tsx +4 -3
- package/src/components/mobile-player/props.tsx +5 -0
- package/src/components/mobile-player/video-async.native.tsx +436 -0
- package/src/components/mobile-player/video.native.tsx +16 -423
- package/src/lib/browser.ts +5 -5
- package/src/streamplace-store/stream.tsx +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@streamplace/components",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.18",
|
|
4
4
|
"description": "Streamplace React (Native) Components",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "src/index.tsx",
|
|
@@ -51,5 +51,5 @@
|
|
|
51
51
|
"build": "tsc",
|
|
52
52
|
"start": "tsc --watch --preserveWatchOutput"
|
|
53
53
|
},
|
|
54
|
-
"gitHead": "
|
|
54
|
+
"gitHead": "c0449c9e843e90abf365ff7f017a74560838d482"
|
|
55
55
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Ellipsis, Reply
|
|
1
|
+
import { Ellipsis, Reply } from "lucide-react-native";
|
|
2
2
|
import { ComponentProps, memo, useEffect, useRef, useState } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import { Platform, Pressable } from "react-native";
|
|
4
|
+
import { FlatList } from "react-native-gesture-handler";
|
|
4
5
|
import Swipeable, {
|
|
5
6
|
SwipeableMethods,
|
|
6
7
|
} from "react-native-gesture-handler/ReanimatedSwipeable";
|
|
@@ -44,7 +45,7 @@ function LeftAction(prog: SharedValue<number>, drag: SharedValue<number>) {
|
|
|
44
45
|
|
|
45
46
|
return (
|
|
46
47
|
<Reanimated.View style={[styleAnimation]}>
|
|
47
|
-
<
|
|
48
|
+
<Ellipsis color="white" />
|
|
48
49
|
</Reanimated.View>
|
|
49
50
|
);
|
|
50
51
|
}
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { useVideoPlayer, VideoPlayerEvents, VideoView } from "expo-video";
|
|
2
|
+
import { ArrowRight } from "lucide-react-native";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { LayoutChangeEvent, Linking } from "react-native";
|
|
5
|
+
import {
|
|
6
|
+
MediaStream,
|
|
7
|
+
RTCView,
|
|
8
|
+
RTCView as RTCViewIngest,
|
|
9
|
+
} from "react-native-webrtc";
|
|
10
|
+
import {
|
|
11
|
+
Button,
|
|
12
|
+
IngestMediaSource,
|
|
13
|
+
PlayerStatus as IngestPlayerStatus,
|
|
14
|
+
PlayerProtocol,
|
|
15
|
+
PlayerStatus,
|
|
16
|
+
Text,
|
|
17
|
+
usePlayerStore as useIngestPlayerStore,
|
|
18
|
+
usePlayerStore,
|
|
19
|
+
useStreamplaceStore,
|
|
20
|
+
View,
|
|
21
|
+
} from "../..";
|
|
22
|
+
import {
|
|
23
|
+
borderRadius,
|
|
24
|
+
colors,
|
|
25
|
+
fontWeight,
|
|
26
|
+
gap,
|
|
27
|
+
h,
|
|
28
|
+
layout,
|
|
29
|
+
m,
|
|
30
|
+
p,
|
|
31
|
+
} from "../../lib/theme/atoms";
|
|
32
|
+
import { srcToUrl } from "./shared";
|
|
33
|
+
import useWebRTC, { useWebRTCIngest } from "./use-webrtc";
|
|
34
|
+
import { mediaDevices, WebRTCMediaStream } from "./webrtc-primitives.native";
|
|
35
|
+
|
|
36
|
+
// Add NativeIngestPlayer to the switch below!
|
|
37
|
+
export default function VideoNative(props?: {
|
|
38
|
+
objectFit?: "contain" | "cover";
|
|
39
|
+
pictureInPictureEnabled?: boolean;
|
|
40
|
+
}) {
|
|
41
|
+
const protocol = usePlayerStore((x) => x.protocol);
|
|
42
|
+
const ingest = usePlayerStore((x) => x.ingestConnectionState) != null;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<View>
|
|
46
|
+
{ingest ? (
|
|
47
|
+
<NativeIngestPlayer objectFit={props?.objectFit} />
|
|
48
|
+
) : protocol === PlayerProtocol.WEBRTC ? (
|
|
49
|
+
<NativeWHEP
|
|
50
|
+
objectFit={props?.objectFit}
|
|
51
|
+
pictureInPictureEnabled={props?.pictureInPictureEnabled}
|
|
52
|
+
/>
|
|
53
|
+
) : (
|
|
54
|
+
<NativeVideo
|
|
55
|
+
objectFit={props?.objectFit}
|
|
56
|
+
pictureInPictureEnabled={props?.pictureInPictureEnabled}
|
|
57
|
+
/>
|
|
58
|
+
)}
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function NativeVideo(props?: {
|
|
64
|
+
objectFit?: "contain" | "cover" | "fill" | "none" | "scale-down";
|
|
65
|
+
pictureInPictureEnabled?: boolean;
|
|
66
|
+
}) {
|
|
67
|
+
const videoRef = useRef<VideoView | null>(null);
|
|
68
|
+
const protocol = usePlayerStore((x) => x.protocol);
|
|
69
|
+
|
|
70
|
+
const selectedRendition = usePlayerStore((x) => x.selectedRendition);
|
|
71
|
+
const src = usePlayerStore((x) => x.src);
|
|
72
|
+
const { url } = srcToUrl({ src: src, selectedRendition }, protocol);
|
|
73
|
+
const setStatus = usePlayerStore((x) => x.setStatus);
|
|
74
|
+
const muted = usePlayerStore((x) => x.muted);
|
|
75
|
+
const volume = usePlayerStore((x) => x.volume);
|
|
76
|
+
const setFullscreen = usePlayerStore((x) => x.setFullscreen);
|
|
77
|
+
const fullscreen = usePlayerStore((x) => x.fullscreen);
|
|
78
|
+
const playerEvent = usePlayerStore((x) => x.playerEvent);
|
|
79
|
+
const spurl = useStreamplaceStore((x) => x.url);
|
|
80
|
+
|
|
81
|
+
const setPlayerWidth = usePlayerStore((x) => x.setPlayerWidth);
|
|
82
|
+
const setPlayerHeight = usePlayerStore((x) => x.setPlayerHeight);
|
|
83
|
+
|
|
84
|
+
// State for live dimensions
|
|
85
|
+
const [dimensions, setDimensions] = useState<{
|
|
86
|
+
width: number;
|
|
87
|
+
height: number;
|
|
88
|
+
}>({ width: 0, height: 0 });
|
|
89
|
+
|
|
90
|
+
const handleLayout = useCallback((event: LayoutChangeEvent) => {
|
|
91
|
+
const { width, height } = event.nativeEvent.layout;
|
|
92
|
+
setDimensions({ width, height });
|
|
93
|
+
setPlayerWidth(width);
|
|
94
|
+
setPlayerHeight(height);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
return () => {
|
|
99
|
+
setStatus(PlayerStatus.START);
|
|
100
|
+
};
|
|
101
|
+
}, [setStatus]);
|
|
102
|
+
|
|
103
|
+
const player = useVideoPlayer(url, (player) => {
|
|
104
|
+
player.addListener("playingChange", (newIsPlaying) => {
|
|
105
|
+
console.log("playingChange", newIsPlaying);
|
|
106
|
+
if (newIsPlaying) {
|
|
107
|
+
setStatus(PlayerStatus.PLAYING);
|
|
108
|
+
} else {
|
|
109
|
+
setStatus(PlayerStatus.WAITING);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
player.loop = true;
|
|
113
|
+
player.muted = muted;
|
|
114
|
+
player.play();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
player.muted = muted;
|
|
119
|
+
}, [muted, player]);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
player.volume = volume;
|
|
123
|
+
}, [volume, player]);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
const subs = (
|
|
127
|
+
[
|
|
128
|
+
"playToEnd",
|
|
129
|
+
"playbackRateChange",
|
|
130
|
+
"playingChange",
|
|
131
|
+
"sourceChange",
|
|
132
|
+
"statusChange",
|
|
133
|
+
"volumeChange",
|
|
134
|
+
] as (keyof VideoPlayerEvents)[]
|
|
135
|
+
).map((evType) => {
|
|
136
|
+
return player.addListener(evType, (...args) => {
|
|
137
|
+
const now = new Date();
|
|
138
|
+
console.log("video native event", evType);
|
|
139
|
+
playerEvent(spurl, now.toISOString(), evType, { args: args });
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
subs.push(
|
|
144
|
+
player.addListener("playingChange", (newIsPlaying) => {
|
|
145
|
+
console.log("playingChange", newIsPlaying);
|
|
146
|
+
if (newIsPlaying) {
|
|
147
|
+
setStatus(PlayerStatus.PLAYING);
|
|
148
|
+
} else {
|
|
149
|
+
setStatus(PlayerStatus.WAITING);
|
|
150
|
+
}
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return () => {
|
|
155
|
+
for (const sub of subs) {
|
|
156
|
+
sub.remove();
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}, [player, playerEvent, setStatus, spurl]);
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<>
|
|
163
|
+
<VideoView
|
|
164
|
+
ref={videoRef}
|
|
165
|
+
player={player}
|
|
166
|
+
allowsFullscreen
|
|
167
|
+
nativeControls={fullscreen}
|
|
168
|
+
onFullscreenEnter={() => {
|
|
169
|
+
setFullscreen(true);
|
|
170
|
+
}}
|
|
171
|
+
onFullscreenExit={() => {
|
|
172
|
+
setFullscreen(false);
|
|
173
|
+
}}
|
|
174
|
+
allowsPictureInPicture={props?.pictureInPictureEnabled !== false}
|
|
175
|
+
onLayout={handleLayout}
|
|
176
|
+
/>
|
|
177
|
+
</>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function NativeWHEP(props?: {
|
|
182
|
+
objectFit?: "contain" | "cover";
|
|
183
|
+
pictureInPictureEnabled?: boolean;
|
|
184
|
+
}) {
|
|
185
|
+
const selectedRendition = usePlayerStore((x) => x.selectedRendition);
|
|
186
|
+
const src = usePlayerStore((x) => x.src);
|
|
187
|
+
const { url } = srcToUrl(
|
|
188
|
+
{ src: src, selectedRendition },
|
|
189
|
+
PlayerProtocol.WEBRTC,
|
|
190
|
+
);
|
|
191
|
+
const [stream, stuck] = useWebRTC(url);
|
|
192
|
+
const status = usePlayerStore((x) => x.status);
|
|
193
|
+
|
|
194
|
+
const setPlayerWidth = usePlayerStore((x) => x.setPlayerWidth);
|
|
195
|
+
const setPlayerHeight = usePlayerStore((x) => x.setPlayerHeight);
|
|
196
|
+
|
|
197
|
+
// PiP support: wire up videoRef (no direct ref for RTCView)
|
|
198
|
+
const setVideoRef = usePlayerStore((x) => x.setVideoRef);
|
|
199
|
+
|
|
200
|
+
// State for live dimensions
|
|
201
|
+
const [dimensions, setDimensions] = useState<{
|
|
202
|
+
width: number;
|
|
203
|
+
height: number;
|
|
204
|
+
}>({ width: 0, height: 0 });
|
|
205
|
+
|
|
206
|
+
const handleLayout = useCallback((event: LayoutChangeEvent) => {
|
|
207
|
+
const { width, height } = event.nativeEvent.layout;
|
|
208
|
+
setDimensions({ width, height });
|
|
209
|
+
setPlayerWidth(width);
|
|
210
|
+
setPlayerHeight(height);
|
|
211
|
+
}, []);
|
|
212
|
+
|
|
213
|
+
const setStatus = usePlayerStore((x) => x.setStatus);
|
|
214
|
+
const muted = usePlayerStore((x) => x.muted);
|
|
215
|
+
const volume = usePlayerStore((x) => x.volume);
|
|
216
|
+
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (stuck && status === PlayerStatus.PLAYING) {
|
|
219
|
+
console.log("setting status to stalled", status);
|
|
220
|
+
setStatus(PlayerStatus.STALLED);
|
|
221
|
+
}
|
|
222
|
+
if (!stuck && status === PlayerStatus.STALLED) {
|
|
223
|
+
console.log("setting status to playing", status);
|
|
224
|
+
setStatus(PlayerStatus.PLAYING);
|
|
225
|
+
}
|
|
226
|
+
}, [stuck, status]);
|
|
227
|
+
|
|
228
|
+
const mediaStream = stream as unknown as MediaStream;
|
|
229
|
+
|
|
230
|
+
// useEffect(() => {
|
|
231
|
+
// if (!mediaStream) {
|
|
232
|
+
// setStatus(PlayerStatus.WAITING);
|
|
233
|
+
// return;
|
|
234
|
+
// }
|
|
235
|
+
// setStatus(PlayerStatus.PLAYING);
|
|
236
|
+
// }, [mediaStream, setStatus]);
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (!mediaStream) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
mediaStream.getTracks().forEach((track) => {
|
|
243
|
+
if (track.kind === "audio") {
|
|
244
|
+
track._setVolume(muted ? 0 : volume);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}, [mediaStream, muted, volume]);
|
|
248
|
+
|
|
249
|
+
// Keep the playerStore videoRef in sync for PiP (if possible)
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
if (typeof setVideoRef === "function") {
|
|
252
|
+
setVideoRef(null); // No direct ref for RTCView, but keep API consistent
|
|
253
|
+
}
|
|
254
|
+
}, [setVideoRef]);
|
|
255
|
+
|
|
256
|
+
if (!mediaStream) {
|
|
257
|
+
return <View></View>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<>
|
|
262
|
+
<RTCView
|
|
263
|
+
mirror={false}
|
|
264
|
+
objectFit={props?.objectFit || "contain"}
|
|
265
|
+
streamURL={mediaStream.toURL()}
|
|
266
|
+
onLayout={handleLayout}
|
|
267
|
+
pictureInPictureEnabled={props?.pictureInPictureEnabled !== false}
|
|
268
|
+
autoStartPictureInPicture={true}
|
|
269
|
+
pictureInPicturePreferredSize={{
|
|
270
|
+
width: 160,
|
|
271
|
+
height: 90,
|
|
272
|
+
}}
|
|
273
|
+
style={{
|
|
274
|
+
minWidth: "100%",
|
|
275
|
+
minHeight: "100%",
|
|
276
|
+
flex: 1,
|
|
277
|
+
}}
|
|
278
|
+
/>
|
|
279
|
+
</>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function NativeIngestPlayer(props?: {
|
|
284
|
+
objectFit?: "contain" | "cover";
|
|
285
|
+
}) {
|
|
286
|
+
const ingestStarting = useIngestPlayerStore((x) => x.ingestStarting);
|
|
287
|
+
const ingestMediaSource = useIngestPlayerStore((x) => x.ingestMediaSource);
|
|
288
|
+
const ingestAutoStart = useIngestPlayerStore((x) => x.ingestAutoStart);
|
|
289
|
+
const setStatus = useIngestPlayerStore((x) => x.setStatus);
|
|
290
|
+
const setVideoRef = usePlayerStore((x) => x.setVideoRef);
|
|
291
|
+
|
|
292
|
+
const [error, setError] = useState<Error | null>(null);
|
|
293
|
+
|
|
294
|
+
const ingestCamera = useIngestPlayerStore((x) => x.ingestCamera);
|
|
295
|
+
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
setStatus(IngestPlayerStatus.PLAYING);
|
|
298
|
+
}, [setStatus]);
|
|
299
|
+
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
if (typeof setVideoRef === "function") {
|
|
302
|
+
setVideoRef(null);
|
|
303
|
+
}
|
|
304
|
+
}, [setVideoRef]);
|
|
305
|
+
|
|
306
|
+
const url = useStreamplaceStore((x) => x.url);
|
|
307
|
+
const [lms, setLocalMediaStream] = useState<WebRTCMediaStream | null>(null);
|
|
308
|
+
const [, setRemoteMediaStream] = useWebRTCIngest({
|
|
309
|
+
endpoint: `${url}/api/ingest/webrtc`,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Use lms directly as localMediaStream
|
|
313
|
+
const localMediaStream = lms;
|
|
314
|
+
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
if (ingestMediaSource === IngestMediaSource.DISPLAY) {
|
|
317
|
+
mediaDevices
|
|
318
|
+
.getDisplayMedia()
|
|
319
|
+
.then((stream: WebRTCMediaStream) => {
|
|
320
|
+
console.log("display media", stream);
|
|
321
|
+
setLocalMediaStream(stream);
|
|
322
|
+
})
|
|
323
|
+
.catch((e: any) => {
|
|
324
|
+
console.log("error getting display media", e);
|
|
325
|
+
console.error("error getting display media", e);
|
|
326
|
+
});
|
|
327
|
+
} else {
|
|
328
|
+
mediaDevices
|
|
329
|
+
.getUserMedia({
|
|
330
|
+
audio: {
|
|
331
|
+
// deviceId: "audio-1",
|
|
332
|
+
// echoCancellation: true,
|
|
333
|
+
// autoGainControl: true,
|
|
334
|
+
// noiseSuppression: true,
|
|
335
|
+
// latency: false,
|
|
336
|
+
// channelCount: false,
|
|
337
|
+
},
|
|
338
|
+
video: {
|
|
339
|
+
facingMode: ingestCamera,
|
|
340
|
+
width: { min: 200, ideal: 1080, max: 2160 },
|
|
341
|
+
height: { min: 200, ideal: 1920, max: 3840 },
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
.then((stream: WebRTCMediaStream) => {
|
|
345
|
+
setLocalMediaStream(stream);
|
|
346
|
+
|
|
347
|
+
let errs: string[] = [];
|
|
348
|
+
if (stream.getAudioTracks().length === 0) {
|
|
349
|
+
console.warn("No audio tracks found in user media stream");
|
|
350
|
+
errs.push("microphone");
|
|
351
|
+
}
|
|
352
|
+
if (stream.getVideoTracks().length === 0) {
|
|
353
|
+
console.warn("No video tracks found in user media stream");
|
|
354
|
+
errs.push("camera");
|
|
355
|
+
}
|
|
356
|
+
if (errs.length > 0) {
|
|
357
|
+
setError(
|
|
358
|
+
new Error(
|
|
359
|
+
`We could not access your ${errs.join(" and ")}. To stream, you need to give us permission to access these.`,
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
} else {
|
|
363
|
+
setError(null);
|
|
364
|
+
}
|
|
365
|
+
})
|
|
366
|
+
.catch((e: any) => {
|
|
367
|
+
console.error("error getting user media", e);
|
|
368
|
+
setError(
|
|
369
|
+
new Error(
|
|
370
|
+
"We could not access your camera or microphone. To stream, you need to give us permission to access these.",
|
|
371
|
+
),
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}, [ingestMediaSource, ingestCamera]);
|
|
376
|
+
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (!ingestStarting && !ingestAutoStart) {
|
|
379
|
+
setRemoteMediaStream(null);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (!localMediaStream) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
console.log("setting remote media stream", localMediaStream);
|
|
386
|
+
// @ts-expect-error: WebRTCMediaStream may not have all MediaStream properties, but is compatible for our use
|
|
387
|
+
setRemoteMediaStream(localMediaStream);
|
|
388
|
+
}, [localMediaStream, ingestStarting, ingestAutoStart, setRemoteMediaStream]);
|
|
389
|
+
|
|
390
|
+
if (!localMediaStream) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (error) {
|
|
395
|
+
return (
|
|
396
|
+
<View
|
|
397
|
+
backgroundColor={colors.destructive[900]}
|
|
398
|
+
style={[p[4], m[4], gap.all[2], { borderRadius: borderRadius.md }]}
|
|
399
|
+
>
|
|
400
|
+
<View>
|
|
401
|
+
<Text style={[fontWeight.semibold]} size="2xl">
|
|
402
|
+
Error encountered!
|
|
403
|
+
</Text>
|
|
404
|
+
</View>
|
|
405
|
+
<Text>{error.message}</Text>
|
|
406
|
+
{error.message.includes(
|
|
407
|
+
"To stream, you need to give us permission to access these.",
|
|
408
|
+
) && (
|
|
409
|
+
<Button
|
|
410
|
+
onPress={Linking.openSettings}
|
|
411
|
+
style={[h[10]]}
|
|
412
|
+
variant="secondary"
|
|
413
|
+
>
|
|
414
|
+
<View style={[layout.flex.row, gap.all[1]]}>
|
|
415
|
+
<Text>Open Settings</Text> <ArrowRight color="white" size="18" />
|
|
416
|
+
</View>
|
|
417
|
+
</Button>
|
|
418
|
+
)}
|
|
419
|
+
</View>
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return (
|
|
424
|
+
<RTCViewIngest
|
|
425
|
+
mirror={ingestCamera !== "environment"}
|
|
426
|
+
objectFit={props?.objectFit || "contain"}
|
|
427
|
+
streamURL={localMediaStream.toURL()}
|
|
428
|
+
zOrder={0}
|
|
429
|
+
style={{
|
|
430
|
+
minWidth: "100%",
|
|
431
|
+
minHeight: "100%",
|
|
432
|
+
flex: 1,
|
|
433
|
+
}}
|
|
434
|
+
/>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
@@ -1,436 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { LayoutChangeEvent, Linking } from "react-native";
|
|
5
|
-
import {
|
|
6
|
-
MediaStream,
|
|
7
|
-
RTCView,
|
|
8
|
-
RTCView as RTCViewIngest,
|
|
9
|
-
} from "react-native-webrtc";
|
|
10
|
-
import {
|
|
11
|
-
Button,
|
|
12
|
-
IngestMediaSource,
|
|
13
|
-
PlayerStatus as IngestPlayerStatus,
|
|
14
|
-
PlayerProtocol,
|
|
15
|
-
PlayerStatus,
|
|
16
|
-
Text,
|
|
17
|
-
usePlayerStore as useIngestPlayerStore,
|
|
18
|
-
usePlayerStore,
|
|
19
|
-
useStreamplaceStore,
|
|
20
|
-
View,
|
|
21
|
-
} from "../..";
|
|
22
|
-
import {
|
|
23
|
-
borderRadius,
|
|
24
|
-
colors,
|
|
25
|
-
fontWeight,
|
|
26
|
-
gap,
|
|
27
|
-
h,
|
|
28
|
-
layout,
|
|
29
|
-
m,
|
|
30
|
-
p,
|
|
31
|
-
} from "../../lib/theme/atoms";
|
|
32
|
-
import { srcToUrl } from "./shared";
|
|
33
|
-
import useWebRTC, { useWebRTCIngest } from "./use-webrtc";
|
|
34
|
-
import { mediaDevices, WebRTCMediaStream } from "./webrtc-primitives.native";
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { VideoNativeProps } from "./props";
|
|
35
4
|
|
|
36
|
-
|
|
37
|
-
export default function VideoNative(props?: {
|
|
38
|
-
objectFit?: "contain" | "cover";
|
|
39
|
-
pictureInPictureEnabled?: boolean;
|
|
40
|
-
}) {
|
|
41
|
-
const protocol = usePlayerStore((x) => x.protocol);
|
|
42
|
-
const ingest = usePlayerStore((x) => x.ingestConnectionState) != null;
|
|
5
|
+
let importPromise: Promise<typeof import("./video-async.native")> | null = null;
|
|
43
6
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
) : protocol === PlayerProtocol.WEBRTC ? (
|
|
49
|
-
<NativeWHEP
|
|
50
|
-
objectFit={props?.objectFit}
|
|
51
|
-
pictureInPictureEnabled={props?.pictureInPictureEnabled}
|
|
52
|
-
/>
|
|
53
|
-
) : (
|
|
54
|
-
<NativeVideo
|
|
55
|
-
objectFit={props?.objectFit}
|
|
56
|
-
pictureInPictureEnabled={props?.pictureInPictureEnabled}
|
|
57
|
-
/>
|
|
58
|
-
)}
|
|
59
|
-
</View>
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function NativeVideo(props?: {
|
|
64
|
-
objectFit?: "contain" | "cover" | "fill" | "none" | "scale-down";
|
|
65
|
-
pictureInPictureEnabled?: boolean;
|
|
66
|
-
}) {
|
|
67
|
-
const videoRef = useRef<VideoView | null>(null);
|
|
68
|
-
const protocol = usePlayerStore((x) => x.protocol);
|
|
69
|
-
|
|
70
|
-
const selectedRendition = usePlayerStore((x) => x.selectedRendition);
|
|
71
|
-
const src = usePlayerStore((x) => x.src);
|
|
72
|
-
const { url } = srcToUrl({ src: src, selectedRendition }, protocol);
|
|
73
|
-
const setStatus = usePlayerStore((x) => x.setStatus);
|
|
74
|
-
const muted = usePlayerStore((x) => x.muted);
|
|
75
|
-
const volume = usePlayerStore((x) => x.volume);
|
|
76
|
-
const setFullscreen = usePlayerStore((x) => x.setFullscreen);
|
|
77
|
-
const fullscreen = usePlayerStore((x) => x.fullscreen);
|
|
78
|
-
const playerEvent = usePlayerStore((x) => x.playerEvent);
|
|
79
|
-
const spurl = useStreamplaceStore((x) => x.url);
|
|
80
|
-
|
|
81
|
-
const setPlayerWidth = usePlayerStore((x) => x.setPlayerWidth);
|
|
82
|
-
const setPlayerHeight = usePlayerStore((x) => x.setPlayerHeight);
|
|
83
|
-
|
|
84
|
-
// State for live dimensions
|
|
85
|
-
const [dimensions, setDimensions] = useState<{
|
|
86
|
-
width: number;
|
|
87
|
-
height: number;
|
|
88
|
-
}>({ width: 0, height: 0 });
|
|
7
|
+
export default function VideoNative(props: VideoNativeProps) {
|
|
8
|
+
if (!importPromise) {
|
|
9
|
+
importPromise = import("./video-async.native");
|
|
10
|
+
}
|
|
89
11
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
setPlayerWidth(width);
|
|
94
|
-
setPlayerHeight(height);
|
|
95
|
-
}, []);
|
|
12
|
+
const [videoNativeModule, setVideoNativeModule] = useState<
|
|
13
|
+
typeof import("./video-async.native") | null
|
|
14
|
+
>(null);
|
|
96
15
|
|
|
97
16
|
useEffect(() => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
};
|
|
101
|
-
}, [setStatus]);
|
|
102
|
-
|
|
103
|
-
const player = useVideoPlayer(url, (player) => {
|
|
104
|
-
player.addListener("playingChange", (newIsPlaying) => {
|
|
105
|
-
console.log("playingChange", newIsPlaying);
|
|
106
|
-
if (newIsPlaying) {
|
|
107
|
-
setStatus(PlayerStatus.PLAYING);
|
|
108
|
-
} else {
|
|
109
|
-
setStatus(PlayerStatus.WAITING);
|
|
110
|
-
}
|
|
17
|
+
importPromise?.then((module) => {
|
|
18
|
+
setVideoNativeModule(module);
|
|
111
19
|
});
|
|
112
|
-
player.loop = true;
|
|
113
|
-
player.muted = muted;
|
|
114
|
-
player.play();
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
useEffect(() => {
|
|
118
|
-
player.muted = muted;
|
|
119
|
-
}, [muted, player]);
|
|
120
|
-
|
|
121
|
-
useEffect(() => {
|
|
122
|
-
player.volume = volume;
|
|
123
|
-
}, [volume, player]);
|
|
124
|
-
|
|
125
|
-
useEffect(() => {
|
|
126
|
-
const subs = (
|
|
127
|
-
[
|
|
128
|
-
"playToEnd",
|
|
129
|
-
"playbackRateChange",
|
|
130
|
-
"playingChange",
|
|
131
|
-
"sourceChange",
|
|
132
|
-
"statusChange",
|
|
133
|
-
"volumeChange",
|
|
134
|
-
] as (keyof VideoPlayerEvents)[]
|
|
135
|
-
).map((evType) => {
|
|
136
|
-
return player.addListener(evType, (...args) => {
|
|
137
|
-
const now = new Date();
|
|
138
|
-
console.log("video native event", evType);
|
|
139
|
-
playerEvent(spurl, now.toISOString(), evType, { args: args });
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
subs.push(
|
|
144
|
-
player.addListener("playingChange", (newIsPlaying) => {
|
|
145
|
-
console.log("playingChange", newIsPlaying);
|
|
146
|
-
if (newIsPlaying) {
|
|
147
|
-
setStatus(PlayerStatus.PLAYING);
|
|
148
|
-
} else {
|
|
149
|
-
setStatus(PlayerStatus.WAITING);
|
|
150
|
-
}
|
|
151
|
-
}),
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
return () => {
|
|
155
|
-
for (const sub of subs) {
|
|
156
|
-
sub.remove();
|
|
157
|
-
}
|
|
158
|
-
};
|
|
159
|
-
}, [player, playerEvent, setStatus, spurl]);
|
|
160
|
-
|
|
161
|
-
return (
|
|
162
|
-
<>
|
|
163
|
-
<VideoView
|
|
164
|
-
ref={videoRef}
|
|
165
|
-
player={player}
|
|
166
|
-
allowsFullscreen
|
|
167
|
-
nativeControls={fullscreen}
|
|
168
|
-
onFullscreenEnter={() => {
|
|
169
|
-
setFullscreen(true);
|
|
170
|
-
}}
|
|
171
|
-
onFullscreenExit={() => {
|
|
172
|
-
setFullscreen(false);
|
|
173
|
-
}}
|
|
174
|
-
allowsPictureInPicture={props?.pictureInPictureEnabled !== false}
|
|
175
|
-
onLayout={handleLayout}
|
|
176
|
-
/>
|
|
177
|
-
</>
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function NativeWHEP(props?: {
|
|
182
|
-
objectFit?: "contain" | "cover";
|
|
183
|
-
pictureInPictureEnabled?: boolean;
|
|
184
|
-
}) {
|
|
185
|
-
const selectedRendition = usePlayerStore((x) => x.selectedRendition);
|
|
186
|
-
const src = usePlayerStore((x) => x.src);
|
|
187
|
-
const { url } = srcToUrl(
|
|
188
|
-
{ src: src, selectedRendition },
|
|
189
|
-
PlayerProtocol.WEBRTC,
|
|
190
|
-
);
|
|
191
|
-
const [stream, stuck] = useWebRTC(url);
|
|
192
|
-
const status = usePlayerStore((x) => x.status);
|
|
193
|
-
|
|
194
|
-
const setPlayerWidth = usePlayerStore((x) => x.setPlayerWidth);
|
|
195
|
-
const setPlayerHeight = usePlayerStore((x) => x.setPlayerHeight);
|
|
196
|
-
|
|
197
|
-
// PiP support: wire up videoRef (no direct ref for RTCView)
|
|
198
|
-
const setVideoRef = usePlayerStore((x) => x.setVideoRef);
|
|
199
|
-
|
|
200
|
-
// State for live dimensions
|
|
201
|
-
const [dimensions, setDimensions] = useState<{
|
|
202
|
-
width: number;
|
|
203
|
-
height: number;
|
|
204
|
-
}>({ width: 0, height: 0 });
|
|
205
|
-
|
|
206
|
-
const handleLayout = useCallback((event: LayoutChangeEvent) => {
|
|
207
|
-
const { width, height } = event.nativeEvent.layout;
|
|
208
|
-
setDimensions({ width, height });
|
|
209
|
-
setPlayerWidth(width);
|
|
210
|
-
setPlayerHeight(height);
|
|
211
20
|
}, []);
|
|
212
21
|
|
|
213
|
-
|
|
214
|
-
const muted = usePlayerStore((x) => x.muted);
|
|
215
|
-
const volume = usePlayerStore((x) => x.volume);
|
|
216
|
-
|
|
217
|
-
useEffect(() => {
|
|
218
|
-
if (stuck && status === PlayerStatus.PLAYING) {
|
|
219
|
-
console.log("setting status to stalled", status);
|
|
220
|
-
setStatus(PlayerStatus.STALLED);
|
|
221
|
-
}
|
|
222
|
-
if (!stuck && status === PlayerStatus.STALLED) {
|
|
223
|
-
console.log("setting status to playing", status);
|
|
224
|
-
setStatus(PlayerStatus.PLAYING);
|
|
225
|
-
}
|
|
226
|
-
}, [stuck, status]);
|
|
227
|
-
|
|
228
|
-
const mediaStream = stream as unknown as MediaStream;
|
|
229
|
-
|
|
230
|
-
// useEffect(() => {
|
|
231
|
-
// if (!mediaStream) {
|
|
232
|
-
// setStatus(PlayerStatus.WAITING);
|
|
233
|
-
// return;
|
|
234
|
-
// }
|
|
235
|
-
// setStatus(PlayerStatus.PLAYING);
|
|
236
|
-
// }, [mediaStream, setStatus]);
|
|
237
|
-
|
|
238
|
-
useEffect(() => {
|
|
239
|
-
if (!mediaStream) {
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
mediaStream.getTracks().forEach((track) => {
|
|
243
|
-
if (track.kind === "audio") {
|
|
244
|
-
track._setVolume(muted ? 0 : volume);
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
}, [mediaStream, muted, volume]);
|
|
248
|
-
|
|
249
|
-
// Keep the playerStore videoRef in sync for PiP (if possible)
|
|
250
|
-
useEffect(() => {
|
|
251
|
-
if (typeof setVideoRef === "function") {
|
|
252
|
-
setVideoRef(null); // No direct ref for RTCView, but keep API consistent
|
|
253
|
-
}
|
|
254
|
-
}, [setVideoRef]);
|
|
255
|
-
|
|
256
|
-
if (!mediaStream) {
|
|
22
|
+
if (!videoNativeModule) {
|
|
257
23
|
return <View></View>;
|
|
258
24
|
}
|
|
259
25
|
|
|
260
|
-
|
|
261
|
-
<>
|
|
262
|
-
<RTCView
|
|
263
|
-
mirror={false}
|
|
264
|
-
objectFit={props?.objectFit || "contain"}
|
|
265
|
-
streamURL={mediaStream.toURL()}
|
|
266
|
-
onLayout={handleLayout}
|
|
267
|
-
pictureInPictureEnabled={props?.pictureInPictureEnabled !== false}
|
|
268
|
-
autoStartPictureInPicture={true}
|
|
269
|
-
pictureInPicturePreferredSize={{
|
|
270
|
-
width: 160,
|
|
271
|
-
height: 90,
|
|
272
|
-
}}
|
|
273
|
-
style={{
|
|
274
|
-
minWidth: "100%",
|
|
275
|
-
minHeight: "100%",
|
|
276
|
-
flex: 1,
|
|
277
|
-
}}
|
|
278
|
-
/>
|
|
279
|
-
</>
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
export function NativeIngestPlayer(props?: {
|
|
284
|
-
objectFit?: "contain" | "cover";
|
|
285
|
-
}) {
|
|
286
|
-
const ingestStarting = useIngestPlayerStore((x) => x.ingestStarting);
|
|
287
|
-
const ingestMediaSource = useIngestPlayerStore((x) => x.ingestMediaSource);
|
|
288
|
-
const ingestAutoStart = useIngestPlayerStore((x) => x.ingestAutoStart);
|
|
289
|
-
const setStatus = useIngestPlayerStore((x) => x.setStatus);
|
|
290
|
-
const setVideoRef = usePlayerStore((x) => x.setVideoRef);
|
|
291
|
-
|
|
292
|
-
const [error, setError] = useState<Error | null>(null);
|
|
293
|
-
|
|
294
|
-
const ingestCamera = useIngestPlayerStore((x) => x.ingestCamera);
|
|
295
|
-
|
|
296
|
-
useEffect(() => {
|
|
297
|
-
setStatus(IngestPlayerStatus.PLAYING);
|
|
298
|
-
}, [setStatus]);
|
|
299
|
-
|
|
300
|
-
useEffect(() => {
|
|
301
|
-
if (typeof setVideoRef === "function") {
|
|
302
|
-
setVideoRef(null);
|
|
303
|
-
}
|
|
304
|
-
}, [setVideoRef]);
|
|
305
|
-
|
|
306
|
-
const url = useStreamplaceStore((x) => x.url);
|
|
307
|
-
const [lms, setLocalMediaStream] = useState<WebRTCMediaStream | null>(null);
|
|
308
|
-
const [, setRemoteMediaStream] = useWebRTCIngest({
|
|
309
|
-
endpoint: `${url}/api/ingest/webrtc`,
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
// Use lms directly as localMediaStream
|
|
313
|
-
const localMediaStream = lms;
|
|
314
|
-
|
|
315
|
-
useEffect(() => {
|
|
316
|
-
if (ingestMediaSource === IngestMediaSource.DISPLAY) {
|
|
317
|
-
mediaDevices
|
|
318
|
-
.getDisplayMedia()
|
|
319
|
-
.then((stream: WebRTCMediaStream) => {
|
|
320
|
-
console.log("display media", stream);
|
|
321
|
-
setLocalMediaStream(stream);
|
|
322
|
-
})
|
|
323
|
-
.catch((e: any) => {
|
|
324
|
-
console.log("error getting display media", e);
|
|
325
|
-
console.error("error getting display media", e);
|
|
326
|
-
});
|
|
327
|
-
} else {
|
|
328
|
-
mediaDevices
|
|
329
|
-
.getUserMedia({
|
|
330
|
-
audio: {
|
|
331
|
-
// deviceId: "audio-1",
|
|
332
|
-
// echoCancellation: true,
|
|
333
|
-
// autoGainControl: true,
|
|
334
|
-
// noiseSuppression: true,
|
|
335
|
-
// latency: false,
|
|
336
|
-
// channelCount: false,
|
|
337
|
-
},
|
|
338
|
-
video: {
|
|
339
|
-
facingMode: ingestCamera,
|
|
340
|
-
width: { min: 200, ideal: 1080, max: 2160 },
|
|
341
|
-
height: { min: 200, ideal: 1920, max: 3840 },
|
|
342
|
-
},
|
|
343
|
-
})
|
|
344
|
-
.then((stream: WebRTCMediaStream) => {
|
|
345
|
-
setLocalMediaStream(stream);
|
|
346
|
-
|
|
347
|
-
let errs: string[] = [];
|
|
348
|
-
if (stream.getAudioTracks().length === 0) {
|
|
349
|
-
console.warn("No audio tracks found in user media stream");
|
|
350
|
-
errs.push("microphone");
|
|
351
|
-
}
|
|
352
|
-
if (stream.getVideoTracks().length === 0) {
|
|
353
|
-
console.warn("No video tracks found in user media stream");
|
|
354
|
-
errs.push("camera");
|
|
355
|
-
}
|
|
356
|
-
if (errs.length > 0) {
|
|
357
|
-
setError(
|
|
358
|
-
new Error(
|
|
359
|
-
`We could not access your ${errs.join(" and ")}. To stream, you need to give us permission to access these.`,
|
|
360
|
-
),
|
|
361
|
-
);
|
|
362
|
-
} else {
|
|
363
|
-
setError(null);
|
|
364
|
-
}
|
|
365
|
-
})
|
|
366
|
-
.catch((e: any) => {
|
|
367
|
-
console.error("error getting user media", e);
|
|
368
|
-
setError(
|
|
369
|
-
new Error(
|
|
370
|
-
"We could not access your camera or microphone. To stream, you need to give us permission to access these.",
|
|
371
|
-
),
|
|
372
|
-
);
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
}, [ingestMediaSource, ingestCamera]);
|
|
376
|
-
|
|
377
|
-
useEffect(() => {
|
|
378
|
-
if (!ingestStarting && !ingestAutoStart) {
|
|
379
|
-
setRemoteMediaStream(null);
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
if (!localMediaStream) {
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
console.log("setting remote media stream", localMediaStream);
|
|
386
|
-
// @ts-expect-error: WebRTCMediaStream may not have all MediaStream properties, but is compatible for our use
|
|
387
|
-
setRemoteMediaStream(localMediaStream);
|
|
388
|
-
}, [localMediaStream, ingestStarting, ingestAutoStart, setRemoteMediaStream]);
|
|
389
|
-
|
|
390
|
-
if (!localMediaStream) {
|
|
391
|
-
return null;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
if (error) {
|
|
395
|
-
return (
|
|
396
|
-
<View
|
|
397
|
-
backgroundColor={colors.destructive[900]}
|
|
398
|
-
style={[p[4], m[4], gap.all[2], { borderRadius: borderRadius.md }]}
|
|
399
|
-
>
|
|
400
|
-
<View>
|
|
401
|
-
<Text style={[fontWeight.semibold]} size="2xl">
|
|
402
|
-
Error encountered!
|
|
403
|
-
</Text>
|
|
404
|
-
</View>
|
|
405
|
-
<Text>{error.message}</Text>
|
|
406
|
-
{error.message.includes(
|
|
407
|
-
"To stream, you need to give us permission to access these.",
|
|
408
|
-
) && (
|
|
409
|
-
<Button
|
|
410
|
-
onPress={Linking.openSettings}
|
|
411
|
-
style={[h[10]]}
|
|
412
|
-
variant="secondary"
|
|
413
|
-
>
|
|
414
|
-
<View style={[layout.flex.row, gap.all[1]]}>
|
|
415
|
-
<Text>Open Settings</Text> <ArrowRight color="white" size="18" />
|
|
416
|
-
</View>
|
|
417
|
-
</Button>
|
|
418
|
-
)}
|
|
419
|
-
</View>
|
|
420
|
-
);
|
|
421
|
-
}
|
|
26
|
+
const VideoNative = videoNativeModule.default;
|
|
422
27
|
|
|
423
|
-
return
|
|
424
|
-
<RTCViewIngest
|
|
425
|
-
mirror={ingestCamera !== "environment"}
|
|
426
|
-
objectFit={props?.objectFit || "contain"}
|
|
427
|
-
streamURL={localMediaStream.toURL()}
|
|
428
|
-
zOrder={0}
|
|
429
|
-
style={{
|
|
430
|
-
minWidth: "100%",
|
|
431
|
-
minHeight: "100%",
|
|
432
|
-
flex: 1,
|
|
433
|
-
}}
|
|
434
|
-
/>
|
|
435
|
-
);
|
|
28
|
+
return <VideoNative {...props} />;
|
|
436
29
|
}
|
package/src/lib/browser.ts
CHANGED
|
@@ -3,7 +3,7 @@ export function getBrowserName(userAgent: string) {
|
|
|
3
3
|
|
|
4
4
|
if (userAgent.includes("Firefox")) {
|
|
5
5
|
// "Mozilla/5.0 (X11; Linux i686; rv:104.0) Gecko/20100101 Firefox/104.0"
|
|
6
|
-
return "
|
|
6
|
+
return "Firefox";
|
|
7
7
|
} else if (userAgent.includes("SamsungBrowser")) {
|
|
8
8
|
// "Mozilla/5.0 (Linux; Android 9; SAMSUNG SM-G955F Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/9.4 Chrome/67.0.3396.87 Mobile Safari/537.36"
|
|
9
9
|
return "Samsung Internet";
|
|
@@ -12,16 +12,16 @@ export function getBrowserName(userAgent: string) {
|
|
|
12
12
|
return "Opera";
|
|
13
13
|
} else if (userAgent.includes("Edge")) {
|
|
14
14
|
// "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299"
|
|
15
|
-
return "
|
|
15
|
+
return "Edge (Legacy)";
|
|
16
16
|
} else if (userAgent.includes("Edg")) {
|
|
17
17
|
// "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36 Edg/104.0.1293.70"
|
|
18
|
-
return "
|
|
18
|
+
return "Edge (Chromium)";
|
|
19
19
|
} else if (userAgent.includes("Chrome")) {
|
|
20
20
|
// "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36"
|
|
21
|
-
return "
|
|
21
|
+
return "Chrome";
|
|
22
22
|
} else if (userAgent.includes("Safari")) {
|
|
23
23
|
// "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Mobile/15E148 Safari/604.1"
|
|
24
|
-
return "
|
|
24
|
+
return "Safari";
|
|
25
25
|
}
|
|
26
26
|
return "unknown";
|
|
27
27
|
}
|
|
@@ -234,10 +234,11 @@ export function useCreateStreamRecord() {
|
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
// get version? only works on andoid/ios
|
|
238
|
-
//
|
|
239
237
|
let platform: string = Platform.OS;
|
|
240
|
-
let platVersion: string = Platform.Version
|
|
238
|
+
let platVersion: string = Platform.Version
|
|
239
|
+
? Platform.Version.toString()
|
|
240
|
+
: "";
|
|
241
|
+
// no Platform.Version on web, so use browser name instead
|
|
241
242
|
if (
|
|
242
243
|
platform === "web" &&
|
|
243
244
|
typeof window !== "undefined" &&
|