@mux/mux-react-native-player 0.1.0

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.
Files changed (56) hide show
  1. package/MuxReactNativePlayer.podspec +37 -0
  2. package/README.md +134 -0
  3. package/android/build.gradle +33 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/com/mux/reactnativeplayer/MuxReactNativePlayerModule.kt +135 -0
  6. package/android/src/main/java/com/mux/reactnativeplayer/MuxVideoRecords.kt +174 -0
  7. package/android/src/main/java/com/mux/reactnativeplayer/MuxVideoView.kt +452 -0
  8. package/android/src/main/res/layout/mux_video_player_view.xml +6 -0
  9. package/assets/MuxRobot_02.gif +0 -0
  10. package/assets/MuxRobot_02@2x.gif +0 -0
  11. package/assets/MuxRobot_03.gif +0 -0
  12. package/assets/MuxRobot_03@2x.gif +0 -0
  13. package/assets/MuxRobot_04.gif +0 -0
  14. package/assets/MuxRobot_04@2x.gif +0 -0
  15. package/assets/MuxRobot_05.gif +0 -0
  16. package/assets/MuxRobot_05@2x.gif +0 -0
  17. package/build/MuxVideoControls.d.ts +21 -0
  18. package/build/MuxVideoControls.d.ts.map +1 -0
  19. package/build/MuxVideoControls.js +1032 -0
  20. package/build/MuxVideoPlayer.d.ts +59 -0
  21. package/build/MuxVideoPlayer.d.ts.map +1 -0
  22. package/build/MuxVideoPlayer.js +265 -0
  23. package/build/MuxVideoView.d.ts +39 -0
  24. package/build/MuxVideoView.d.ts.map +1 -0
  25. package/build/MuxVideoView.js +254 -0
  26. package/build/NativeMuxVideoView.d.ts +5 -0
  27. package/build/NativeMuxVideoView.d.ts.map +1 -0
  28. package/build/NativeMuxVideoView.js +4 -0
  29. package/build/index.d.ts +6 -0
  30. package/build/index.d.ts.map +1 -0
  31. package/build/index.js +3 -0
  32. package/build/normalizeSource.d.ts +7 -0
  33. package/build/normalizeSource.d.ts.map +1 -0
  34. package/build/normalizeSource.js +76 -0
  35. package/build/screenOrientation.d.ts +3 -0
  36. package/build/screenOrientation.d.ts.map +1 -0
  37. package/build/screenOrientation.js +38 -0
  38. package/build/types.d.ts +170 -0
  39. package/build/types.d.ts.map +1 -0
  40. package/build/types.js +1 -0
  41. package/expo-module.config.json +13 -0
  42. package/ios/MuxReactNativePlayerModule.swift +139 -0
  43. package/ios/MuxVideoRecords.swift +212 -0
  44. package/ios/MuxVideoView.swift +502 -0
  45. package/package.json +69 -0
  46. package/plugin/index.d.ts +11 -0
  47. package/plugin/index.js +1 -0
  48. package/plugin/withMuxReactNativePlayer.js +203 -0
  49. package/src/MuxVideoControls.tsx +1772 -0
  50. package/src/MuxVideoPlayer.ts +338 -0
  51. package/src/MuxVideoView.tsx +412 -0
  52. package/src/NativeMuxVideoView.ts +15 -0
  53. package/src/index.ts +32 -0
  54. package/src/normalizeSource.ts +101 -0
  55. package/src/screenOrientation.ts +46 -0
  56. package/src/types.ts +228 -0
@@ -0,0 +1,502 @@
1
+ import AVFoundation
2
+ import AVKit
3
+ import ExpoModulesCore
4
+ import MuxPlayerSwift
5
+ import UIKit
6
+
7
+ final class MuxVideoView: ExpoView {
8
+ private let onStatusChange = EventDispatcher()
9
+ private let onPlayingChange = EventDispatcher()
10
+ private let onTimeUpdate = EventDispatcher()
11
+ private let onSourceLoad = EventDispatcher()
12
+ private let onSourceError = EventDispatcher()
13
+
14
+ private let playerViewController = AVPlayerViewController()
15
+ private var sourceFingerprint: String?
16
+ private var currentPlaybackId: String?
17
+ private var didEmitSourceLoad = false
18
+ private var didLoadLegibleGroup = false
19
+ private var didReachEnd = false
20
+ private var muted = false
21
+ private var volume: Float = 1
22
+ private var loop = false
23
+ private var playbackRate: Float = 1
24
+ private var shouldPlay = false
25
+ private var timeUpdateInterval: TimeInterval = 0.5
26
+ private var startupBufferDuration: TimeInterval = 0
27
+ private var timeUpdateTimer: Timer?
28
+ private var statusObservation: NSKeyValueObservation?
29
+ private var timeControlObservation: NSKeyValueObservation?
30
+
31
+ required init(appContext: AppContext? = nil) {
32
+ super.init(appContext: appContext)
33
+
34
+ playerViewController.view.frame = bounds
35
+ playerViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
36
+ playerViewController.showsPlaybackControls = true
37
+ playerViewController.videoGravity = .resizeAspect
38
+ addSubview(playerViewController.view)
39
+ }
40
+
41
+ deinit {
42
+ releasePlayer()
43
+ stopTimeUpdates()
44
+ }
45
+
46
+ override func didMoveToWindow() {
47
+ super.didMoveToWindow()
48
+
49
+ if window == nil {
50
+ stopTimeUpdates()
51
+ } else {
52
+ startTimeUpdates()
53
+ }
54
+ }
55
+
56
+ func setSource(_ source: MuxVideoSourceRecord?) {
57
+ guard let source else {
58
+ release()
59
+ return
60
+ }
61
+
62
+ guard source.fingerprint != sourceFingerprint else {
63
+ return
64
+ }
65
+
66
+ releasePlayer()
67
+ sourceFingerprint = source.fingerprint
68
+ currentPlaybackId = source.playbackId
69
+ didEmitSourceLoad = false
70
+ didLoadLegibleGroup = false
71
+ didReachEnd = false
72
+ sendStatusChange(status: "loading")
73
+
74
+ playerViewController.prepare(
75
+ playbackID: source.playbackId,
76
+ playbackOptions: source.toPlaybackOptions(),
77
+ monitoringOptions: source.toMonitoringOptions()
78
+ )
79
+
80
+ observePlayer()
81
+ applyPlayerConfiguration()
82
+ startTimeUpdates()
83
+
84
+ if shouldPlay {
85
+ playerViewController.player?.play()
86
+ }
87
+ }
88
+
89
+ func setNativeControls(_ enabled: Bool) {
90
+ playerViewController.showsPlaybackControls = enabled
91
+ }
92
+
93
+ func setContentFit(_ contentFit: String) {
94
+ switch contentFit {
95
+ case "cover":
96
+ playerViewController.videoGravity = .resizeAspectFill
97
+ case "fill":
98
+ playerViewController.videoGravity = .resize
99
+ default:
100
+ playerViewController.videoGravity = .resizeAspect
101
+ }
102
+ }
103
+
104
+ func setAllowsPictureInPicture(_ enabled: Bool) {
105
+ playerViewController.allowsPictureInPicturePlayback = enabled
106
+ }
107
+
108
+ func setTimeUpdateEventInterval(_ interval: Double) {
109
+ timeUpdateInterval = max(0.1, interval)
110
+ stopTimeUpdates()
111
+ startTimeUpdates()
112
+ }
113
+
114
+ func setStartupBufferDuration(_ duration: Double) {
115
+ startupBufferDuration = max(0, duration)
116
+ playerViewController.player?.currentItem?.preferredForwardBufferDuration = startupBufferDuration
117
+ }
118
+
119
+ func setPlayWhenReady(_ playWhenReady: Bool) {
120
+ if shouldPlay == playWhenReady {
121
+ return
122
+ }
123
+
124
+ if playWhenReady {
125
+ play()
126
+ } else {
127
+ pause()
128
+ }
129
+ }
130
+
131
+ func setMuted(_ muted: Bool) {
132
+ self.muted = muted
133
+ playerViewController.player?.isMuted = muted
134
+ sendStatusChange()
135
+ }
136
+
137
+ func setVolume(_ volume: Double) {
138
+ self.volume = Float(min(1, max(0, volume)))
139
+ playerViewController.player?.volume = self.volume
140
+ sendStatusChange()
141
+ }
142
+
143
+ func setLoop(_ loop: Bool) {
144
+ self.loop = loop
145
+ sendStatusChange()
146
+ }
147
+
148
+ func setPlaybackRate(_ rate: Double) {
149
+ playbackRate = Float(min(4, max(0.25, rate)))
150
+ if playerViewController.player?.rate ?? 0 > 0 {
151
+ playerViewController.player?.rate = playbackRate
152
+ }
153
+ sendStatusChange()
154
+ }
155
+
156
+ func setCaptionTrack(_ trackId: String?) {
157
+ guard
158
+ let item = playerViewController.player?.currentItem,
159
+ let group = item.asset.mediaSelectionGroup(forMediaCharacteristic: .legible)
160
+ else {
161
+ return
162
+ }
163
+
164
+ guard let trackId else {
165
+ item.select(nil, in: group)
166
+ sendStatusChange()
167
+ return
168
+ }
169
+
170
+ guard
171
+ let index = Int(trackId),
172
+ group.options.indices.contains(index)
173
+ else {
174
+ return
175
+ }
176
+
177
+ item.select(group.options[index], in: group)
178
+ sendStatusChange()
179
+ }
180
+
181
+ func play() {
182
+ shouldPlay = true
183
+ didReachEnd = false
184
+ startPlaybackIfPossible()
185
+ sendStatusChange()
186
+ }
187
+
188
+ func pause() {
189
+ shouldPlay = false
190
+ playerViewController.player?.pause()
191
+ sendStatusChange()
192
+ }
193
+
194
+ func replay() {
195
+ shouldPlay = true
196
+ didReachEnd = false
197
+ seekTo(0)
198
+ play()
199
+ }
200
+
201
+ func seekBy(_ seconds: Double) {
202
+ seekTo(currentTimeSeconds() + seconds)
203
+ }
204
+
205
+ func seekTo(_ seconds: Double) {
206
+ didReachEnd = false
207
+ let target = CMTime(seconds: max(0, seconds), preferredTimescale: 600)
208
+ playerViewController.player?.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero)
209
+ sendStatusChange()
210
+ sendTimeUpdate()
211
+ }
212
+
213
+ func release() {
214
+ releasePlayer()
215
+ sourceFingerprint = nil
216
+ currentPlaybackId = nil
217
+ didEmitSourceLoad = false
218
+ didLoadLegibleGroup = false
219
+ didReachEnd = false
220
+ shouldPlay = false
221
+ sendStatusChange(status: "idle")
222
+ }
223
+
224
+ private func observePlayer() {
225
+ guard let player = playerViewController.player else {
226
+ return
227
+ }
228
+
229
+ statusObservation = player.observe(\.currentItem?.status, options: [.initial, .new]) { [weak self] _, _ in
230
+ DispatchQueue.main.async {
231
+ self?.handlePlayerStatusUpdate()
232
+ }
233
+ }
234
+
235
+ timeControlObservation = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, _ in
236
+ DispatchQueue.main.async {
237
+ self?.sendStatusChange()
238
+ }
239
+ }
240
+
241
+ NotificationCenter.default.addObserver(
242
+ self,
243
+ selector: #selector(handlePlaybackEnded),
244
+ name: .AVPlayerItemDidPlayToEndTime,
245
+ object: player.currentItem
246
+ )
247
+
248
+ NotificationCenter.default.addObserver(
249
+ self,
250
+ selector: #selector(handlePlaybackFailed(_:)),
251
+ name: .AVPlayerItemFailedToPlayToEndTime,
252
+ object: player.currentItem
253
+ )
254
+ }
255
+
256
+ private func handlePlayerStatusUpdate() {
257
+ guard let item = playerViewController.player?.currentItem else {
258
+ return
259
+ }
260
+
261
+ switch item.status {
262
+ case .readyToPlay:
263
+ if !didEmitSourceLoad {
264
+ didEmitSourceLoad = true
265
+ onSourceLoad([
266
+ "playbackId": currentPlaybackId ?? "",
267
+ "duration": durationSeconds(),
268
+ "captionTracks": captionTracksPayload(),
269
+ "selectedCaptionTrackId": selectedCaptionTrackId() ?? NSNull(),
270
+ ])
271
+ }
272
+ loadLegibleGroupIfNeeded(for: item)
273
+ if shouldPlay && playerViewController.player?.rate == 0 {
274
+ startPlaybackIfPossible()
275
+ }
276
+ sendStatusChange()
277
+ case .failed:
278
+ let message = item.error?.localizedDescription ?? "Mux playback failed."
279
+ onSourceError([
280
+ "playbackId": currentPlaybackId ?? "",
281
+ "message": message,
282
+ "code": item.error.map { "\(($0 as NSError).code)" } ?? "",
283
+ ])
284
+ sendStatusChange(status: "error", error: message)
285
+ default:
286
+ sendStatusChange(status: "loading")
287
+ }
288
+ }
289
+
290
+ @objc private func handlePlaybackEnded() {
291
+ if loop {
292
+ seekTo(0)
293
+ play()
294
+ return
295
+ }
296
+
297
+ didReachEnd = true
298
+ shouldPlay = false
299
+ sendStatusChange(status: "ended")
300
+ }
301
+
302
+ @objc private func handlePlaybackFailed(_ notification: Notification) {
303
+ let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error
304
+ let message = error?.localizedDescription ?? "Mux playback failed."
305
+ onSourceError([
306
+ "playbackId": currentPlaybackId ?? "",
307
+ "message": message,
308
+ "code": error.map { "\(($0 as NSError).code)" } ?? "",
309
+ ])
310
+ sendStatusChange(status: "error", error: message)
311
+ }
312
+
313
+ private func applyPlayerConfiguration() {
314
+ let player = playerViewController.player
315
+ player?.automaticallyWaitsToMinimizeStalling = true
316
+ player?.currentItem?.preferredForwardBufferDuration = startupBufferDuration
317
+ player?.isMuted = muted
318
+ player?.volume = volume
319
+ if shouldPlay {
320
+ startPlaybackIfPossible()
321
+ }
322
+ }
323
+
324
+ private func startPlaybackIfPossible() {
325
+ guard let player = playerViewController.player else {
326
+ return
327
+ }
328
+
329
+ player.automaticallyWaitsToMinimizeStalling = true
330
+ player.currentItem?.preferredForwardBufferDuration = startupBufferDuration
331
+ player.play()
332
+
333
+ if playbackRate != 1, player.currentItem?.status == .readyToPlay {
334
+ player.rate = playbackRate
335
+ }
336
+ }
337
+
338
+ private func releasePlayer() {
339
+ NotificationCenter.default.removeObserver(self)
340
+ statusObservation = nil
341
+ timeControlObservation = nil
342
+ playerViewController.stopMonitoring()
343
+ playerViewController.player?.pause()
344
+ playerViewController.player = nil
345
+ }
346
+
347
+ private func startTimeUpdates() {
348
+ stopTimeUpdates()
349
+ guard window != nil else {
350
+ return
351
+ }
352
+ timeUpdateTimer = Timer.scheduledTimer(withTimeInterval: timeUpdateInterval, repeats: true) { [weak self] _ in
353
+ self?.sendTimeUpdate()
354
+ }
355
+ }
356
+
357
+ private func stopTimeUpdates() {
358
+ timeUpdateTimer?.invalidate()
359
+ timeUpdateTimer = nil
360
+ }
361
+
362
+ private func sendTimeUpdate() {
363
+ onTimeUpdate([
364
+ "currentTime": currentTimeSeconds(),
365
+ "duration": durationSeconds(),
366
+ "bufferedPosition": bufferedPositionSeconds(),
367
+ ])
368
+ }
369
+
370
+ private func sendStatusChange(status: String? = nil, error: String? = nil) {
371
+ var payload: [String: Any] = [
372
+ "status": status ?? inferStatus(),
373
+ "currentTime": currentTimeSeconds(),
374
+ "duration": durationSeconds(),
375
+ "bufferedPosition": bufferedPositionSeconds(),
376
+ "muted": muted,
377
+ "volume": Double(volume),
378
+ "loop": loop,
379
+ "playbackRate": Double(playbackRate),
380
+ "captionTracks": captionTracksPayload(),
381
+ "selectedCaptionTrackId": selectedCaptionTrackId() ?? NSNull(),
382
+ ]
383
+
384
+ if let error {
385
+ payload["error"] = error
386
+ }
387
+
388
+ onStatusChange(payload)
389
+ onPlayingChange(["isPlaying": payload["status"] as? String == "playing"])
390
+ }
391
+
392
+ private func inferStatus() -> String {
393
+ guard let player = playerViewController.player else {
394
+ return currentPlaybackId == nil ? "idle" : "loading"
395
+ }
396
+
397
+ if didReachEnd {
398
+ return "ended"
399
+ }
400
+
401
+ if player.currentItem?.status == .failed {
402
+ return "error"
403
+ }
404
+
405
+ if player.currentItem?.status != .readyToPlay {
406
+ return "loading"
407
+ }
408
+
409
+ if player.timeControlStatus == .waitingToPlayAtSpecifiedRate {
410
+ return "buffering"
411
+ }
412
+
413
+ return player.rate > 0 ? "playing" : "paused"
414
+ }
415
+
416
+ private func currentTimeSeconds() -> Double {
417
+ guard let seconds = playerViewController.player?.currentTime().seconds, seconds.isFinite else {
418
+ return 0
419
+ }
420
+ return max(0, seconds)
421
+ }
422
+
423
+ private func durationSeconds() -> Double {
424
+ guard let duration = playerViewController.player?.currentItem?.duration.seconds, duration.isFinite else {
425
+ return 0
426
+ }
427
+ return max(0, duration)
428
+ }
429
+
430
+ private func bufferedPositionSeconds() -> Double {
431
+ guard let range = playerViewController.player?.currentItem?.loadedTimeRanges.first?.timeRangeValue else {
432
+ return 0
433
+ }
434
+ let end = CMTimeGetSeconds(CMTimeAdd(range.start, range.duration))
435
+ return end.isFinite ? max(0, end) : 0
436
+ }
437
+
438
+ private func loadLegibleGroupIfNeeded(for item: AVPlayerItem) {
439
+ guard !didLoadLegibleGroup else {
440
+ return
441
+ }
442
+ didLoadLegibleGroup = true
443
+ let asset = item.asset
444
+ let requestedFingerprint = sourceFingerprint
445
+ asset.loadValuesAsynchronously(forKeys: ["availableMediaCharacteristicsWithMediaSelectionOptions"]) { [weak self] in
446
+ DispatchQueue.main.async {
447
+ guard let self, self.sourceFingerprint == requestedFingerprint else {
448
+ return
449
+ }
450
+ self.sendStatusChange()
451
+ }
452
+ }
453
+ }
454
+
455
+ private func captionTracksPayload() -> [[String: Any]] {
456
+ guard
457
+ let item = playerViewController.player?.currentItem,
458
+ let group = item.asset.mediaSelectionGroup(forMediaCharacteristic: .legible)
459
+ else {
460
+ return []
461
+ }
462
+
463
+ return group.options.enumerated().map { index, option in
464
+ var payload: [String: Any] = [
465
+ "id": "\(index)",
466
+ "label": option.displayName,
467
+ "kind": captionTrackKind(option),
468
+ ]
469
+
470
+ if let language = option.extendedLanguageTag ?? option.locale?.identifier {
471
+ payload["language"] = language
472
+ }
473
+
474
+ return payload
475
+ }
476
+ }
477
+
478
+ private func selectedCaptionTrackId() -> String? {
479
+ guard
480
+ let item = playerViewController.player?.currentItem,
481
+ let group = item.asset.mediaSelectionGroup(forMediaCharacteristic: .legible),
482
+ let selected = item.currentMediaSelection.selectedMediaOption(in: group),
483
+ let index = group.options.firstIndex(where: { $0 === selected })
484
+ else {
485
+ return nil
486
+ }
487
+
488
+ return "\(index)"
489
+ }
490
+
491
+ private func captionTrackKind(_ option: AVMediaSelectionOption) -> String {
492
+ if option.hasMediaCharacteristic(.containsOnlyForcedSubtitles) {
493
+ return "forced"
494
+ }
495
+
496
+ if option.hasMediaCharacteristic(.transcribesSpokenDialogForAccessibility) {
497
+ return "captions"
498
+ }
499
+
500
+ return "subtitles"
501
+ }
502
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@mux/mux-react-native-player",
3
+ "version": "0.1.0",
4
+ "description": "Mux-owned React Native video player powered by Mux Player for iOS and Android.",
5
+ "license": "MIT",
6
+ "main": "build/index.js",
7
+ "types": "build/index.d.ts",
8
+ "react-native": "src/index.ts",
9
+ "source": "src/index.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./build/index.d.ts",
13
+ "react-native": "./src/index.ts",
14
+ "default": "./build/index.js"
15
+ },
16
+ "./plugin": "./plugin/index.js"
17
+ },
18
+ "files": [
19
+ "android/build.gradle",
20
+ "android/src",
21
+ "assets/*.gif",
22
+ "build/**/*.{js,d.ts,d.ts.map}",
23
+ "ios/*.{h,m,mm,swift}",
24
+ "plugin",
25
+ "src",
26
+ "expo-module.config.json",
27
+ "MuxReactNativePlayer.podspec",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.json",
32
+ "prepack": "npm run build",
33
+ "test": "vitest run",
34
+ "typecheck": "tsc -p tsconfig.json --noEmit",
35
+ "check:plugin": "node --check plugin/withMuxReactNativePlayer.js",
36
+ "example:install": "npm --prefix example install",
37
+ "example:robots": "npm --prefix example run robots",
38
+ "example:ios:prebuild": "npm --prefix example run prebuild:ios -- --clean",
39
+ "example:ios": "npm --prefix example run ios",
40
+ "example:ios:build": "npm --prefix example run ios:build",
41
+ "android": "expo run:android",
42
+ "ios": "expo run:ios"
43
+ },
44
+ "keywords": [
45
+ "mux",
46
+ "react-native",
47
+ "expo",
48
+ "video",
49
+ "player"
50
+ ],
51
+ "peerDependencies": {
52
+ "expo": "*",
53
+ "expo-linear-gradient": "*",
54
+ "expo-modules-core": "*",
55
+ "react": "*",
56
+ "react-native": "*"
57
+ },
58
+ "devDependencies": {
59
+ "@expo/config-plugins": "^55.0.8",
60
+ "@types/react": "^19.2.14",
61
+ "expo": "^55.0.23",
62
+ "expo-linear-gradient": "^55.0.13",
63
+ "expo-modules-core": "^55.0.25",
64
+ "react": "19.2.0",
65
+ "react-native": "0.83.6",
66
+ "typescript": "~5.9.2",
67
+ "vitest": "^3.1.4"
68
+ }
69
+ }
@@ -0,0 +1,11 @@
1
+ import type { ConfigPlugin } from '@expo/config-plugins';
2
+
3
+ export type MuxReactNativePlayerPluginProps = {
4
+ enableBackgroundAudio?: boolean;
5
+ enablePictureInPicture?: boolean;
6
+ };
7
+
8
+ declare const withMuxReactNativePlayer: ConfigPlugin<MuxReactNativePlayerPluginProps | void>;
9
+
10
+ export default withMuxReactNativePlayer;
11
+ export { withMuxReactNativePlayer };
@@ -0,0 +1 @@
1
+ module.exports = require('./withMuxReactNativePlayer');