From 7717c404294c6ec62695a6f5d6a712123bdcc876 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 25 Mar 2026 17:28:35 +0100 Subject: [PATCH] . --- .eslintrc.js | 8 + .watchmanconfig | 1 + ReactNativeVideo.podspec | 59 + android/CMakeLists.txt | 29 + android/build.gradle | 250 ++++ android/fix-prefab.gradle | 51 + android/gradle.properties | 13 + android/src/main/AndroidManifest.xml | 3 + android/src/main/AndroidManifestNew.xml | 3 + android/src/main/cpp/cpp-adapter.cpp | 6 + .../com/twg/video/core/AudioFocusManager.kt | 235 ++++ .../java/com/twg/video/core/VideoError.kt | 105 ++ .../java/com/twg/video/core/VideoManager.kt | 292 +++++ .../ResizeMode+AspectRatioFrameLayout.kt | 20 + .../core/extensions/SubtitleType+toString.kt | 14 + .../VideoPlaybackService+ServiceManagment.kt | 59 + .../core/fragments/FullscreenVideoFragment.kt | 265 ++++ .../PictureInPictureHelperFragment.kt | 85 ++ .../twg/video/core/player/DRMManagerSpec.kt | 23 + .../core/player/DataSourceFactoryUtils.kt | 51 + .../twg/video/core/player/MediaItemUtils.kt | 141 +++ .../twg/video/core/player/MediaSourceUtils.kt | 112 ++ .../player/OnAudioFocusChangedListener.kt | 25 + .../twg/video/core/plugins/PluginsRegistry.kt | 223 ++++ .../core/plugins/ReactNativeVideoPlugin.kt | 173 +++ .../recivers/AudioBecomingNoisyReceiver.kt | 38 + .../CustomMediaNotificationProvider.kt | 151 +++ .../playback/VideoPlaybackCallback.kt | 96 ++ .../services/playback/VideoPlaybackService.kt | 219 ++++ .../VideoPlaybackServiceConnection.kt | 59 + .../video/core/utils/PictureInPictureUtils.kt | 153 +++ .../core/utils/SmallVideoPlayerOptimizer.kt | 157 +++ .../com/twg/video/core/utils/SourceLoader.kt | 48 + .../twg/video/core/utils/TextTrackUtils.kt | 175 +++ .../com/twg/video/core/utils/Threading.kt | 75 ++ .../twg/video/core/utils/VideoFileHelper.kt | 51 + .../video/core/utils/VideoInformationUtils.kt | 67 + .../video/core/utils/VideoOrientationUtils.kt | 30 + .../hybrids/videoplayer/HybridVideoPlayer.kt | 621 ++++++++++ .../videoplayer/HybridVideoPlayerFactory.kt | 16 + .../HybridVideoPlayerEventEmitter.kt | 180 +++ .../HybridVideoPlayerSource.kt | 67 + .../HybridVideoPlayerSourceFactory.kt | 58 + .../HybridVideoViewViewManager.kt | 187 +++ .../HybridVideoViewViewManagerFactory.kt | 13 + .../java/com/twg/video/react/VideoPackage.kt | 25 + .../twg/video/react/VideoViewViewManager.kt | 78 ++ .../main/java/com/twg/video/view/VideoView.kt | 565 +++++++++ .../main/res/layout/player_view_surface.xml | 14 + .../main/res/layout/player_view_texture.xml | 15 + .../RNCVideoViewManagerDelegate.java | 32 + .../RNCVideoViewManagerInterface.java | 16 + .../media3/exoplayer/dash/DashMediaSource.kt | 35 + .../media3/exoplayer/dash/DashUtil.kt | 11 + .../exoplayer/dash/manifest/AdaptationSet.kt | 9 + .../exoplayer/dash/manifest/DashManifest.kt | 10 + .../media3/exoplayer/dash/manifest/Period.kt | 7 + .../exoplayer/dash/manifest/Representation.kt | 8 + .../media3/exoplayer/hls/HlsMediaSource.kt | 33 + app.plugin.js | 1 + babel.config.js | 5 + ios/Video-Bridging-Header.h | 1 + .../AVAsset+estimatedMemoryUsage.swift | 51 + .../Extensions/AVAssetTrack+orientation.swift | 41 + ios/core/Extensions/AVMetadataItem+make.swift | 18 + .../AVPlayerItem+externalSubtitles.swift | 35 + .../AVPlayerItem+getBufferedDurration.swift | 34 + .../AVPlayerItem+setBufferConfig.swift | 37 + .../AVPlayerViewController+Fullscreen.swift | 21 + ...layerViewController+PictureInPicture.swift | 24 + .../AVURLAsset+getAssetInformation.swift | 74 ++ .../NSObject+PerformIfResponds.swift | 30 + .../Extensions/ResizeMode+VideoGravity.swift | 24 + ios/core/HLSSubtitleInjector.swift | 381 ++++++ ios/core/NowPlayingInfoCenterManager.swift | 356 ++++++ ios/core/Plugins/PluginsRegistry.swift | 102 ++ ios/core/Plugins/ReactNativeVideoPlugin.swift | 91 ++ ios/core/Spec/DRMManagerSpec.swift | 14 + .../Spec/NativeVideoPlayerSourceSpec.swift | 36 + ios/core/Spec/NativeVideoPlayerSpec.swift | 42 + ios/core/Utils/ExternalSubtitlesUtils.swift | 121 ++ ios/core/Utils/HLSManifestParser.swift | 170 +++ ios/core/Utils/Weak.swift | 26 + ios/core/VideoError.swift | 169 +++ ios/core/VideoFileHelper.swift | 48 + ios/core/VideoManager.swift | 414 +++++++ ios/core/VideoPlayerObserver.swift | 294 +++++ .../HybridVideoPlayer+Events.swift | 205 +++ .../VideoPlayer/HybridVideoPlayer.swift | 599 +++++++++ .../HybridVideoPlayerFactory.swift | 15 + .../HybridVideoPlayerEventEmitter.swift | 203 +++ .../HybridVideoPlayerSource.swift | 166 +++ .../HybridVideoPlayerSourceFactory.swift | 29 + .../VideoPlayerSource/SourceLoader.swift | 83 ++ .../HybridVideoViewViewManager.swift | 266 ++++ .../HybridVideoViewViewManagerFactory.swift | 14 + ios/view/VideoComponentView.swift | 274 ++++ ios/view/VideoComponentViewObserver.swift | 176 +++ ios/view/fabric/RCTVideoViewComponentView.h | 10 + ios/view/fabric/RCTVideoViewComponentView.mm | 94 ++ ios/view/fabric/RCTVideoViewViewManager.mm | 14 + ios/view/paper/RCTVideoViewComponentView.h | 9 + ios/view/paper/RCTVideoViewComponentView.mm | 45 + ios/view/paper/RCTVideoViewViewManager.m | 18 + lib/commonjs/core/VideoPlayer.js | 276 +++++ lib/commonjs/core/VideoPlayer.js.map | 1 + lib/commonjs/core/VideoPlayer.web.js | 274 ++++ lib/commonjs/core/VideoPlayer.web.js.map | 1 + lib/commonjs/core/events/VideoPlayerEvents.js | 13 + .../core/events/VideoPlayerEvents.js.map | 1 + .../core/events/VideoPlayerEvents.native.js | 32 + .../events/VideoPlayerEvents.native.js.map | 1 + .../core/events/VideoPlayerEvents.web.js | 18 + .../core/events/VideoPlayerEvents.web.js.map | 1 + .../core/events/VideoPlayerEventsBase.js | 63 + .../core/events/VideoPlayerEventsBase.js.map | 1 + lib/commonjs/core/hooks/useEvent.js | 24 + lib/commonjs/core/hooks/useEvent.js.map | 1 + lib/commonjs/core/hooks/useManagedInstance.js | 79 ++ .../core/hooks/useManagedInstance.js.map | 1 + lib/commonjs/core/hooks/useVideoPlayer.js | 62 + lib/commonjs/core/hooks/useVideoPlayer.js.map | 1 + lib/commonjs/core/types/BufferConfig.js | 2 + lib/commonjs/core/types/BufferConfig.js.map | 1 + lib/commonjs/core/types/DrmParams.js | 2 + lib/commonjs/core/types/DrmParams.js.map | 1 + lib/commonjs/core/types/EventEmitter.js | 6 + lib/commonjs/core/types/EventEmitter.js.map | 1 + lib/commonjs/core/types/Events.js | 14 + lib/commonjs/core/types/Events.js.map | 1 + .../core/types/IgnoreSilentSwitchMode.js | 2 + .../core/types/IgnoreSilentSwitchMode.js.map | 1 + lib/commonjs/core/types/MixAudioMode.js | 2 + lib/commonjs/core/types/MixAudioMode.js.map | 1 + lib/commonjs/core/types/ResizeMode.js | 2 + lib/commonjs/core/types/ResizeMode.js.map | 1 + lib/commonjs/core/types/TextTrack.js | 2 + lib/commonjs/core/types/TextTrack.js.map | 1 + lib/commonjs/core/types/Utils.js | 2 + lib/commonjs/core/types/Utils.js.map | 1 + lib/commonjs/core/types/VideoConfig.js | 6 + lib/commonjs/core/types/VideoConfig.js.map | 1 + lib/commonjs/core/types/VideoError.js | 108 ++ lib/commonjs/core/types/VideoError.js.map | 1 + lib/commonjs/core/types/VideoInformation.js | 6 + .../core/types/VideoInformation.js.map | 1 + lib/commonjs/core/types/VideoOrientation.js | 2 + .../core/types/VideoOrientation.js.map | 1 + lib/commonjs/core/types/VideoPlayerBase.js | 6 + .../core/types/VideoPlayerBase.js.map | 1 + .../core/types/VideoPlayerSourceBase.js | 6 + .../core/types/VideoPlayerSourceBase.js.map | 1 + lib/commonjs/core/types/VideoPlayerStatus.js | 2 + .../core/types/VideoPlayerStatus.js.map | 1 + lib/commonjs/core/utils/playerFactory.js | 31 + lib/commonjs/core/utils/playerFactory.js.map | 1 + lib/commonjs/core/utils/sourceFactory.js | 136 ++ lib/commonjs/core/utils/sourceFactory.js.map | 1 + lib/commonjs/core/utils/sourceUtils.js | 18 + lib/commonjs/core/utils/sourceUtils.js.map | 1 + .../core/video-view/NativeVideoView.js | 18 + .../core/video-view/NativeVideoView.js.map | 1 + lib/commonjs/core/video-view/VideoView.js | 208 ++++ lib/commonjs/core/video-view/VideoView.js.map | 1 + lib/commonjs/core/video-view/VideoView.web.js | 139 +++ .../core/video-view/VideoView.web.js.map | 1 + .../core/video-view/VideoViewProps.js | 6 + .../core/video-view/VideoViewProps.js.map | 1 + lib/commonjs/core/web/MediaSession.js | 96 ++ lib/commonjs/core/web/MediaSession.js.map | 1 + lib/commonjs/core/web/WebEventEmitter.js | 238 ++++ lib/commonjs/core/web/WebEventEmitter.js.map | 1 + lib/commonjs/expo-plugins/@types.js | 2 + lib/commonjs/expo-plugins/@types.js.map | 1 + lib/commonjs/expo-plugins/getPackageInfo.js | 27 + .../expo-plugins/getPackageInfo.js.map | 1 + .../expo-plugins/withAndroidExtensions.js | 40 + .../expo-plugins/withAndroidExtensions.js.map | 1 + .../withAndroidNotificationControls.js | 34 + .../withAndroidNotificationControls.js.map | 1 + .../withAndroidPictureInPicture.js | 23 + .../withAndroidPictureInPicture.js.map | 1 + .../expo-plugins/withBackgroundAudio.js | 26 + .../expo-plugins/withBackgroundAudio.js.map | 1 + .../expo-plugins/withReactNativeVideo.js | 31 + .../expo-plugins/withReactNativeVideo.js.map | 1 + lib/commonjs/expo-plugins/writeToPodfile.js | 59 + .../expo-plugins/writeToPodfile.js.map | 1 + lib/commonjs/index.js | 35 + lib/commonjs/index.js.map | 1 + lib/commonjs/package.json | 1 + .../spec/fabric/VideoViewNativeComponent.ts | 17 + lib/commonjs/spec/nitro/VideoPlayer.nitro.js | 6 + .../spec/nitro/VideoPlayer.nitro.js.map | 1 + .../nitro/VideoPlayerEventEmitter.nitro.js | 6 + .../VideoPlayerEventEmitter.nitro.js.map | 1 + .../spec/nitro/VideoPlayerSource.nitro.js | 6 + .../spec/nitro/VideoPlayerSource.nitro.js.map | 1 + .../spec/nitro/VideoViewViewManager.nitro.js | 6 + .../nitro/VideoViewViewManager.nitro.js.map | 1 + lib/module/core/VideoPlayer.js | 272 ++++ lib/module/core/VideoPlayer.js.map | 1 + lib/module/core/VideoPlayer.web.js | 270 ++++ lib/module/core/VideoPlayer.web.js.map | 1 + lib/module/core/events/VideoPlayerEvents.js | 6 + .../core/events/VideoPlayerEvents.js.map | 1 + .../core/events/VideoPlayerEvents.native.js | 27 + .../events/VideoPlayerEvents.native.js.map | 1 + .../core/events/VideoPlayerEvents.web.js | 13 + .../core/events/VideoPlayerEvents.web.js.map | 1 + .../core/events/VideoPlayerEventsBase.js | 58 + .../core/events/VideoPlayerEventsBase.js.map | 1 + lib/module/core/hooks/useEvent.js | 19 + lib/module/core/hooks/useEvent.js.map | 1 + lib/module/core/hooks/useManagedInstance.js | 75 ++ .../core/hooks/useManagedInstance.js.map | 1 + lib/module/core/hooks/useVideoPlayer.js | 57 + lib/module/core/hooks/useVideoPlayer.js.map | 1 + lib/module/core/types/BufferConfig.js | 2 + lib/module/core/types/BufferConfig.js.map | 1 + lib/module/core/types/DrmParams.js | 2 + lib/module/core/types/DrmParams.js.map | 1 + lib/module/core/types/EventEmitter.js | 4 + lib/module/core/types/EventEmitter.js.map | 1 + lib/module/core/types/Events.js | 10 + lib/module/core/types/Events.js.map | 1 + .../core/types/IgnoreSilentSwitchMode.js | 2 + .../core/types/IgnoreSilentSwitchMode.js.map | 1 + lib/module/core/types/MixAudioMode.js | 2 + lib/module/core/types/MixAudioMode.js.map | 1 + lib/module/core/types/ResizeMode.js | 2 + lib/module/core/types/ResizeMode.js.map | 1 + lib/module/core/types/TextTrack.js | 2 + lib/module/core/types/TextTrack.js.map | 1 + lib/module/core/types/Utils.js | 2 + lib/module/core/types/Utils.js.map | 1 + lib/module/core/types/VideoConfig.js | 4 + lib/module/core/types/VideoConfig.js.map | 1 + lib/module/core/types/VideoError.js | 100 ++ lib/module/core/types/VideoError.js.map | 1 + lib/module/core/types/VideoInformation.js | 4 + lib/module/core/types/VideoInformation.js.map | 1 + lib/module/core/types/VideoOrientation.js | 2 + lib/module/core/types/VideoOrientation.js.map | 1 + lib/module/core/types/VideoPlayerBase.js | 4 + lib/module/core/types/VideoPlayerBase.js.map | 1 + .../core/types/VideoPlayerSourceBase.js | 4 + .../core/types/VideoPlayerSourceBase.js.map | 1 + lib/module/core/types/VideoPlayerStatus.js | 2 + .../core/types/VideoPlayerStatus.js.map | 1 + lib/module/core/utils/playerFactory.js | 26 + lib/module/core/utils/playerFactory.js.map | 1 + lib/module/core/utils/sourceFactory.js | 129 ++ lib/module/core/utils/sourceFactory.js.map | 1 + lib/module/core/utils/sourceUtils.js | 13 + lib/module/core/utils/sourceUtils.js.map | 1 + lib/module/core/video-view/NativeVideoView.js | 13 + .../core/video-view/NativeVideoView.js.map | 1 + lib/module/core/video-view/VideoView.js | 203 +++ lib/module/core/video-view/VideoView.js.map | 1 + lib/module/core/video-view/VideoView.web.js | 135 ++ .../core/video-view/VideoView.web.js.map | 1 + lib/module/core/video-view/VideoViewProps.js | 4 + .../core/video-view/VideoViewProps.js.map | 1 + lib/module/core/web/MediaSession.js | 91 ++ lib/module/core/web/MediaSession.js.map | 1 + lib/module/core/web/WebEventEmitter.js | 234 ++++ lib/module/core/web/WebEventEmitter.js.map | 1 + lib/module/expo-plugins/@types.js | 2 + lib/module/expo-plugins/@types.js.map | 1 + lib/module/expo-plugins/getPackageInfo.js | 23 + lib/module/expo-plugins/getPackageInfo.js.map | 1 + .../expo-plugins/withAndroidExtensions.js | 35 + .../expo-plugins/withAndroidExtensions.js.map | 1 + .../withAndroidNotificationControls.js | 29 + .../withAndroidNotificationControls.js.map | 1 + .../withAndroidPictureInPicture.js | 18 + .../withAndroidPictureInPicture.js.map | 1 + .../expo-plugins/withBackgroundAudio.js | 22 + .../expo-plugins/withBackgroundAudio.js.map | 1 + .../expo-plugins/withReactNativeVideo.js | 27 + .../expo-plugins/withReactNativeVideo.js.map | 1 + lib/module/expo-plugins/writeToPodfile.js | 53 + lib/module/expo-plugins/writeToPodfile.js.map | 1 + lib/module/index.js | 7 + lib/module/index.js.map | 1 + lib/module/package.json | 1 + .../spec/fabric/VideoViewNativeComponent.ts | 17 + lib/module/spec/nitro/VideoPlayer.nitro.js | 4 + .../spec/nitro/VideoPlayer.nitro.js.map | 1 + .../nitro/VideoPlayerEventEmitter.nitro.js | 4 + .../VideoPlayerEventEmitter.nitro.js.map | 1 + .../spec/nitro/VideoPlayerSource.nitro.js | 4 + .../spec/nitro/VideoPlayerSource.nitro.js.map | 1 + .../spec/nitro/VideoViewViewManager.nitro.js | 4 + .../nitro/VideoViewViewManager.nitro.js.map | 1 + lib/typescript/commonjs/package.json | 1 + .../commonjs/src/core/VideoPlayer.d.ts | 59 + .../commonjs/src/core/VideoPlayer.d.ts.map | 1 + .../src/core/events/VideoPlayerEvents.d.ts | 2 + .../core/events/VideoPlayerEvents.d.ts.map | 1 + .../core/events/VideoPlayerEvents.native.d.ts | 7 + .../events/VideoPlayerEvents.native.d.ts.map | 1 + .../core/events/VideoPlayerEventsBase.d.ts | 12 + .../events/VideoPlayerEventsBase.d.ts.map | 1 + .../commonjs/src/core/hooks/useEvent.d.ts | 11 + .../commonjs/src/core/hooks/useEvent.d.ts.map | 1 + .../src/core/hooks/useManagedInstance.d.ts | 2 + .../core/hooks/useManagedInstance.d.ts.map | 1 + .../src/core/hooks/useVideoPlayer.d.ts | 16 + .../src/core/hooks/useVideoPlayer.d.ts.map | 1 + .../commonjs/src/core/types/BufferConfig.d.ts | 97 ++ .../src/core/types/BufferConfig.d.ts.map | 1 + .../commonjs/src/core/types/DrmParams.d.ts | 64 + .../src/core/types/DrmParams.d.ts.map | 1 + .../commonjs/src/core/types/EventEmitter.d.ts | 36 + .../src/core/types/EventEmitter.d.ts.map | 1 + .../commonjs/src/core/types/Events.d.ts | 223 ++++ .../commonjs/src/core/types/Events.d.ts.map | 1 + .../core/types/IgnoreSilentSwitchMode.d.ts | 2 + .../types/IgnoreSilentSwitchMode.d.ts.map | 1 + .../commonjs/src/core/types/MixAudioMode.d.ts | 2 + .../src/core/types/MixAudioMode.d.ts.map | 1 + .../commonjs/src/core/types/ResizeMode.d.ts | 10 + .../src/core/types/ResizeMode.d.ts.map | 1 + .../commonjs/src/core/types/TextTrack.d.ts | 20 + .../src/core/types/TextTrack.d.ts.map | 1 + .../commonjs/src/core/types/Utils.d.ts | 2 + .../commonjs/src/core/types/Utils.d.ts.map | 1 + .../commonjs/src/core/types/VideoConfig.d.ts | 129 ++ .../src/core/types/VideoConfig.d.ts.map | 1 + .../commonjs/src/core/types/VideoError.d.ts | 27 + .../src/core/types/VideoError.d.ts.map | 1 + .../src/core/types/VideoInformation.d.ts | 38 + .../src/core/types/VideoInformation.d.ts.map | 1 + .../src/core/types/VideoOrientation.d.ts | 2 + .../src/core/types/VideoOrientation.d.ts.map | 1 + .../src/core/types/VideoPlayerBase.d.ts | 149 +++ .../src/core/types/VideoPlayerBase.d.ts.map | 1 + .../src/core/types/VideoPlayerSourceBase.d.ts | 17 + .../core/types/VideoPlayerSourceBase.d.ts.map | 1 + .../src/core/types/VideoPlayerStatus.d.ts | 9 + .../src/core/types/VideoPlayerStatus.d.ts.map | 1 + .../src/core/utils/playerFactory.d.ts | 2 + .../src/core/utils/playerFactory.d.ts.map | 1 + .../src/core/utils/sourceFactory.d.ts | 28 + .../src/core/utils/sourceFactory.d.ts.map | 1 + .../commonjs/src/core/utils/sourceUtils.d.ts | 3 + .../src/core/utils/sourceUtils.d.ts.map | 1 + .../src/core/video-view/NativeVideoView.d.ts | 2 + .../core/video-view/NativeVideoView.d.ts.map | 1 + .../src/core/video-view/VideoView.d.ts | 5 + .../src/core/video-view/VideoView.d.ts.map | 1 + .../src/core/video-view/VideoViewProps.d.ts | 82 ++ .../core/video-view/VideoViewProps.d.ts.map | 1 + .../commonjs/src/expo-plugins/@types.d.ts | 34 + .../commonjs/src/expo-plugins/@types.d.ts.map | 1 + .../src/expo-plugins/getPackageInfo.d.ts | 5 + .../src/expo-plugins/getPackageInfo.d.ts.map | 1 + .../expo-plugins/withAndroidExtensions.d.ts | 8 + .../withAndroidExtensions.d.ts.map | 1 + .../withAndroidNotificationControls.d.ts | 3 + .../withAndroidNotificationControls.d.ts.map | 1 + .../withAndroidPictureInPicture.d.ts | 3 + .../withAndroidPictureInPicture.d.ts.map | 1 + .../src/expo-plugins/withBackgroundAudio.d.ts | 7 + .../expo-plugins/withBackgroundAudio.d.ts.map | 1 + .../expo-plugins/withReactNativeVideo.d.ts | 5 + .../withReactNativeVideo.d.ts.map | 1 + .../src/expo-plugins/writeToPodfile.d.ts | 2 + .../src/expo-plugins/writeToPodfile.d.ts.map | 1 + lib/typescript/commonjs/src/index.d.ts | 14 + lib/typescript/commonjs/src/index.d.ts.map | 1 + .../spec/fabric/VideoViewNativeComponent.d.ts | 12 + .../fabric/VideoViewNativeComponent.d.ts.map | 1 + .../src/spec/nitro/VideoPlayer.nitro.d.ts | 49 + .../src/spec/nitro/VideoPlayer.nitro.d.ts.map | 1 + .../nitro/VideoPlayerEventEmitter.nitro.d.ts | 150 +++ .../VideoPlayerEventEmitter.nitro.d.ts.map | 1 + .../spec/nitro/VideoPlayerSource.nitro.d.ts | 21 + .../nitro/VideoPlayerSource.nitro.d.ts.map | 1 + .../nitro/VideoViewViewManager.nitro.d.ts | 2 + .../nitro/VideoViewViewManager.nitro.d.ts.map | 1 + lib/typescript/module/package.json | 1 + .../module/src/core/VideoPlayer.d.ts | 59 + .../module/src/core/VideoPlayer.d.ts.map | 1 + .../src/core/events/VideoPlayerEvents.d.ts | 2 + .../core/events/VideoPlayerEvents.d.ts.map | 1 + .../core/events/VideoPlayerEvents.native.d.ts | 7 + .../events/VideoPlayerEvents.native.d.ts.map | 1 + .../core/events/VideoPlayerEventsBase.d.ts | 12 + .../events/VideoPlayerEventsBase.d.ts.map | 1 + .../module/src/core/hooks/useEvent.d.ts | 11 + .../module/src/core/hooks/useEvent.d.ts.map | 1 + .../src/core/hooks/useManagedInstance.d.ts | 2 + .../core/hooks/useManagedInstance.d.ts.map | 1 + .../module/src/core/hooks/useVideoPlayer.d.ts | 16 + .../src/core/hooks/useVideoPlayer.d.ts.map | 1 + .../module/src/core/types/BufferConfig.d.ts | 97 ++ .../src/core/types/BufferConfig.d.ts.map | 1 + .../module/src/core/types/DrmParams.d.ts | 64 + .../module/src/core/types/DrmParams.d.ts.map | 1 + .../module/src/core/types/EventEmitter.d.ts | 36 + .../src/core/types/EventEmitter.d.ts.map | 1 + .../module/src/core/types/Events.d.ts | 223 ++++ .../module/src/core/types/Events.d.ts.map | 1 + .../core/types/IgnoreSilentSwitchMode.d.ts | 2 + .../types/IgnoreSilentSwitchMode.d.ts.map | 1 + .../module/src/core/types/MixAudioMode.d.ts | 2 + .../src/core/types/MixAudioMode.d.ts.map | 1 + .../module/src/core/types/ResizeMode.d.ts | 10 + .../module/src/core/types/ResizeMode.d.ts.map | 1 + .../module/src/core/types/TextTrack.d.ts | 20 + .../module/src/core/types/TextTrack.d.ts.map | 1 + .../module/src/core/types/Utils.d.ts | 2 + .../module/src/core/types/Utils.d.ts.map | 1 + .../module/src/core/types/VideoConfig.d.ts | 129 ++ .../src/core/types/VideoConfig.d.ts.map | 1 + .../module/src/core/types/VideoError.d.ts | 27 + .../module/src/core/types/VideoError.d.ts.map | 1 + .../src/core/types/VideoInformation.d.ts | 38 + .../src/core/types/VideoInformation.d.ts.map | 1 + .../src/core/types/VideoOrientation.d.ts | 2 + .../src/core/types/VideoOrientation.d.ts.map | 1 + .../src/core/types/VideoPlayerBase.d.ts | 149 +++ .../src/core/types/VideoPlayerBase.d.ts.map | 1 + .../src/core/types/VideoPlayerSourceBase.d.ts | 17 + .../core/types/VideoPlayerSourceBase.d.ts.map | 1 + .../src/core/types/VideoPlayerStatus.d.ts | 9 + .../src/core/types/VideoPlayerStatus.d.ts.map | 1 + .../module/src/core/utils/playerFactory.d.ts | 2 + .../src/core/utils/playerFactory.d.ts.map | 1 + .../module/src/core/utils/sourceFactory.d.ts | 28 + .../src/core/utils/sourceFactory.d.ts.map | 1 + .../module/src/core/utils/sourceUtils.d.ts | 3 + .../src/core/utils/sourceUtils.d.ts.map | 1 + .../src/core/video-view/NativeVideoView.d.ts | 2 + .../core/video-view/NativeVideoView.d.ts.map | 1 + .../module/src/core/video-view/VideoView.d.ts | 5 + .../src/core/video-view/VideoView.d.ts.map | 1 + .../src/core/video-view/VideoViewProps.d.ts | 82 ++ .../core/video-view/VideoViewProps.d.ts.map | 1 + .../module/src/expo-plugins/@types.d.ts | 34 + .../module/src/expo-plugins/@types.d.ts.map | 1 + .../src/expo-plugins/getPackageInfo.d.ts | 5 + .../src/expo-plugins/getPackageInfo.d.ts.map | 1 + .../expo-plugins/withAndroidExtensions.d.ts | 8 + .../withAndroidExtensions.d.ts.map | 1 + .../withAndroidNotificationControls.d.ts | 3 + .../withAndroidNotificationControls.d.ts.map | 1 + .../withAndroidPictureInPicture.d.ts | 3 + .../withAndroidPictureInPicture.d.ts.map | 1 + .../src/expo-plugins/withBackgroundAudio.d.ts | 7 + .../expo-plugins/withBackgroundAudio.d.ts.map | 1 + .../expo-plugins/withReactNativeVideo.d.ts | 5 + .../withReactNativeVideo.d.ts.map | 1 + .../src/expo-plugins/writeToPodfile.d.ts | 2 + .../src/expo-plugins/writeToPodfile.d.ts.map | 1 + lib/typescript/module/src/index.d.ts | 14 + lib/typescript/module/src/index.d.ts.map | 1 + .../spec/fabric/VideoViewNativeComponent.d.ts | 12 + .../fabric/VideoViewNativeComponent.d.ts.map | 1 + .../src/spec/nitro/VideoPlayer.nitro.d.ts | 49 + .../src/spec/nitro/VideoPlayer.nitro.d.ts.map | 1 + .../nitro/VideoPlayerEventEmitter.nitro.d.ts | 150 +++ .../VideoPlayerEventEmitter.nitro.d.ts.map | 1 + .../spec/nitro/VideoPlayerSource.nitro.d.ts | 21 + .../nitro/VideoPlayerSource.nitro.d.ts.map | 1 + .../nitro/VideoViewViewManager.nitro.d.ts | 2 + .../nitro/VideoViewViewManager.nitro.d.ts.map | 1 + nitro.json | 28 + nitrogen/generated/.gitattributes | 1 + .../ReactNativeVideo+autolinking.cmake | 95 ++ .../ReactNativeVideo+autolinking.gradle | 27 + .../android/ReactNativeVideoOnLoad.cpp | 122 ++ .../android/ReactNativeVideoOnLoad.hpp | 34 + .../generated/android/c++/JBandwidthData.hpp | 65 + .../generated/android/c++/JBufferConfig.hpp | 101 ++ .../android/c++/JCustomVideoMetadata.hpp | 74 ++ ...se_std__string_____OnGetLicensePayload.hpp | 126 ++ nitrogen/generated/android/c++/JFunc_void.hpp | 75 ++ .../android/c++/JFunc_void_BandwidthData.hpp | 78 ++ .../android/c++/JFunc_void_TimedMetadata.hpp | 81 ++ .../c++/JFunc_void_VideoPlayerStatus.hpp | 77 ++ .../generated/android/c++/JFunc_void_bool.hpp | 75 ++ .../android/c++/JFunc_void_double.hpp | 75 ++ .../android/c++/JFunc_void_onLoadData.hpp | 79 ++ .../c++/JFunc_void_onLoadStartData.hpp | 82 ++ .../JFunc_void_onPlaybackStateChangeData.hpp | 77 ++ .../android/c++/JFunc_void_onProgressData.hpp | 77 ++ .../c++/JFunc_void_onVolumeChangeData.hpp | 77 ++ ...__variant_nitro__NullType__TextTrack__.hpp | 83 ++ .../JFunc_void_std__vector_std__string_.hpp | 95 ++ .../JHybridVideoPlayerEventEmitterSpec.cpp | 224 ++++ .../JHybridVideoPlayerEventEmitterSpec.hpp | 82 ++ .../c++/JHybridVideoPlayerFactorySpec.cpp | 60 + .../c++/JHybridVideoPlayerFactorySpec.hpp | 63 + .../JHybridVideoPlayerSourceFactorySpec.cpp | 106 ++ .../JHybridVideoPlayerSourceFactorySpec.hpp | 64 + .../c++/JHybridVideoPlayerSourceSpec.cpp | 124 ++ .../c++/JHybridVideoPlayerSourceSpec.hpp | 64 + .../android/c++/JHybridVideoPlayerSpec.cpp | 284 +++++ .../android/c++/JHybridVideoPlayerSpec.hpp | 97 ++ ...JHybridVideoViewViewManagerFactorySpec.cpp | 56 + ...JHybridVideoViewViewManagerFactorySpec.hpp | 63 + .../c++/JHybridVideoViewViewManagerSpec.cpp | 185 +++ .../c++/JHybridVideoViewViewManagerSpec.hpp | 87 ++ .../android/c++/JIgnoreSilentSwitchMode.hpp | 61 + .../android/c++/JListenerSubscription.hpp | 67 + .../android/c++/JLivePlaybackParams.hpp | 73 ++ .../generated/android/c++/JMixAudioMode.hpp | 64 + .../android/c++/JNativeDrmParams.hpp | 111 ++ .../android/c++/JNativeExternalSubtitle.hpp | 71 ++ .../android/c++/JNativeVideoConfig.hpp | 136 ++ .../android/c++/JOnGetLicensePayload.hpp | 69 ++ .../generated/android/c++/JResizeMode.hpp | 64 + .../generated/android/c++/JResolution.hpp | 61 + .../generated/android/c++/JSourceType.hpp | 58 + .../generated/android/c++/JSubtitleType.hpp | 67 + .../generated/android/c++/JSurfaceType.hpp | 58 + nitrogen/generated/android/c++/JTextTrack.hpp | 70 ++ .../generated/android/c++/JTimedMetadata.hpp | 78 ++ .../android/c++/JTimedMetadataObject.hpp | 61 + ...t_NullType_HybridVideoPlayerSourceSpec.cpp | 26 + ...t_NullType_HybridVideoPlayerSourceSpec.hpp | 72 ++ .../c++/JVariant_NullType_TextTrack.cpp | 26 + .../c++/JVariant_NullType_TextTrack.hpp | 73 ++ .../android/c++/JVideoInformation.hpp | 86 ++ .../android/c++/JVideoOrientation.hpp | 73 ++ .../android/c++/JVideoPlayerStatus.hpp | 64 + .../generated/android/c++/JonLoadData.hpp | 74 ++ .../android/c++/JonLoadStartData.hpp | 65 + .../c++/JonPlaybackStateChangeData.hpp | 61 + .../generated/android/c++/JonProgressData.hpp | 61 + .../android/c++/JonVolumeChangeData.hpp | 61 + .../com/margelo/nitro/video/BandwidthData.kt | 44 + .../com/margelo/nitro/video/BufferConfig.kt | 68 + .../nitro/video/CustomVideoMetadata.kt | 50 + ...ise_std__string_____OnGetLicensePayload.kt | 80 ++ .../com/margelo/nitro/video/Func_void.kt | 80 ++ .../nitro/video/Func_void_BandwidthData.kt | 80 ++ .../nitro/video/Func_void_TimedMetadata.kt | 80 ++ .../video/Func_void_VideoPlayerStatus.kt | 80 ++ .../com/margelo/nitro/video/Func_void_bool.kt | 80 ++ .../margelo/nitro/video/Func_void_double.kt | 80 ++ .../nitro/video/Func_void_onLoadData.kt | 80 ++ .../nitro/video/Func_void_onLoadStartData.kt | 80 ++ .../Func_void_onPlaybackStateChangeData.kt | 80 ++ .../nitro/video/Func_void_onProgressData.kt | 80 ++ .../video/Func_void_onVolumeChangeData.kt | 80 ++ ...d__variant_nitro__NullType__TextTrack__.kt | 80 ++ .../Func_void_std__vector_std__string_.kt | 80 ++ .../HybridVideoPlayerEventEmitterSpec.kt | 226 ++++ .../video/HybridVideoPlayerFactorySpec.kt | 54 + .../HybridVideoPlayerSourceFactorySpec.kt | 58 + .../video/HybridVideoPlayerSourceSpec.kt | 61 + .../nitro/video/HybridVideoPlayerSpec.kt | 174 +++ .../HybridVideoViewViewManagerFactorySpec.kt | 54 + .../video/HybridVideoViewViewManagerSpec.kt | 168 +++ .../nitro/video/IgnoreSilentSwitchMode.kt | 24 + .../nitro/video/ListenerSubscription.kt | 42 + .../margelo/nitro/video/LivePlaybackParams.kt | 50 + .../com/margelo/nitro/video/MixAudioMode.kt | 25 + .../margelo/nitro/video/NativeDrmParams.kt | 60 + .../nitro/video/NativeExternalSubtitle.kt | 47 + .../margelo/nitro/video/NativeVideoConfig.kt | 56 + .../nitro/video/OnGetLicensePayload.kt | 47 + .../nitro/video/ReactNativeVideoOnLoad.kt | 35 + .../com/margelo/nitro/video/ResizeMode.kt | 25 + .../com/margelo/nitro/video/Resolution.kt | 41 + .../com/margelo/nitro/video/SourceType.kt | 23 + .../com/margelo/nitro/video/SubtitleType.kt | 26 + .../com/margelo/nitro/video/SurfaceType.kt | 23 + .../com/margelo/nitro/video/TextTrack.kt | 47 + .../com/margelo/nitro/video/TimedMetadata.kt | 38 + .../nitro/video/TimedMetadataObject.kt | 41 + ...nt_NullType_HybridVideoPlayerSourceSpec.kt | 53 + .../nitro/video/Variant_NullType_TextTrack.kt | 53 + .../margelo/nitro/video/VideoInformation.kt | 59 + .../margelo/nitro/video/VideoOrientation.kt | 28 + .../margelo/nitro/video/VideoPlayerStatus.kt | 25 + .../com/margelo/nitro/video/onLoadData.kt | 50 + .../margelo/nitro/video/onLoadStartData.kt | 41 + .../nitro/video/onPlaybackStateChangeData.kt | 41 + .../com/margelo/nitro/video/onProgressData.kt | 41 + .../margelo/nitro/video/onVolumeChangeData.kt | 41 + .../ios/ReactNativeVideo+autolinking.rb | 60 + .../ios/ReactNativeVideo-Swift-Cxx-Bridge.cpp | 280 +++++ .../ios/ReactNativeVideo-Swift-Cxx-Bridge.hpp | 1097 +++++++++++++++++ .../ReactNativeVideo-Swift-Cxx-Umbrella.hpp | 162 +++ .../ios/ReactNativeVideoAutolinking.mm | 49 + .../ios/ReactNativeVideoAutolinking.swift | 50 + ...HybridVideoPlayerEventEmitterSpecSwift.cpp | 11 + ...HybridVideoPlayerEventEmitterSpecSwift.hpp | 279 +++++ .../c++/HybridVideoPlayerFactorySpecSwift.cpp | 11 + .../c++/HybridVideoPlayerFactorySpecSwift.hpp | 87 ++ ...ybridVideoPlayerSourceFactorySpecSwift.cpp | 11 + ...ybridVideoPlayerSourceFactorySpecSwift.hpp | 125 ++ .../c++/HybridVideoPlayerSourceSpecSwift.cpp | 11 + .../c++/HybridVideoPlayerSourceSpecSwift.hpp | 125 ++ .../ios/c++/HybridVideoPlayerSpecSwift.cpp | 11 + .../ios/c++/HybridVideoPlayerSpecSwift.hpp | 248 ++++ ...idVideoViewViewManagerFactorySpecSwift.cpp | 11 + ...idVideoViewViewManagerFactorySpecSwift.hpp | 84 ++ .../HybridVideoViewViewManagerSpecSwift.cpp | 11 + .../HybridVideoViewViewManagerSpecSwift.hpp | 217 ++++ .../generated/ios/swift/BandwidthData.swift | 65 + .../generated/ios/swift/BufferConfig.swift | 201 +++ .../ios/swift/CustomVideoMetadata.swift | 114 ++ ..._std__string_____OnGetLicensePayload.swift | 61 + nitrogen/generated/ios/swift/Func_void.swift | 46 + .../ios/swift/Func_void_BandwidthData.swift | 46 + .../ios/swift/Func_void_TimedMetadata.swift | 46 + .../swift/Func_void_VideoInformation.swift | 46 + .../swift/Func_void_VideoPlayerStatus.swift | 46 + .../generated/ios/swift/Func_void_bool.swift | 46 + .../ios/swift/Func_void_double.swift | 46 + .../ios/swift/Func_void_onLoadData.swift | 46 + .../ios/swift/Func_void_onLoadStartData.swift | 46 + .../Func_void_onPlaybackStateChangeData.swift | 46 + .../ios/swift/Func_void_onProgressData.swift | 46 + .../swift/Func_void_onVolumeChangeData.swift | 46 + .../swift/Func_void_std__exception_ptr.swift | 46 + ...variant_nitro__NullType__TextTrack__.swift | 65 + ...td__shared_ptr_Promise_std__string__.swift | 66 + .../ios/swift/Func_void_std__string.swift | 46 + .../Func_void_std__vector_std__string_.swift | 46 + .../HybridVideoPlayerEventEmitterSpec.swift | 74 ++ ...ybridVideoPlayerEventEmitterSpec_cxx.swift | 479 +++++++ .../swift/HybridVideoPlayerFactorySpec.swift | 55 + .../HybridVideoPlayerFactorySpec_cxx.swift | 145 +++ .../HybridVideoPlayerSourceFactorySpec.swift | 56 + ...bridVideoPlayerSourceFactorySpec_cxx.swift | 156 +++ .../swift/HybridVideoPlayerSourceSpec.swift | 56 + .../HybridVideoPlayerSourceSpec_cxx.swift | 157 +++ .../ios/swift/HybridVideoPlayerSpec.swift | 79 ++ .../ios/swift/HybridVideoPlayerSpec_cxx.swift | 471 +++++++ ...ybridVideoViewViewManagerFactorySpec.swift | 55 + ...dVideoViewViewManagerFactorySpec_cxx.swift | 141 +++ .../HybridVideoViewViewManagerSpec.swift | 72 ++ .../HybridVideoViewViewManagerSpec_cxx.swift | 390 ++++++ .../ios/swift/IgnoreSilentSwitchMode.swift | 44 + .../ios/swift/ListenerSubscription.swift | 37 + .../ios/swift/LivePlaybackParams.swift | 114 ++ .../generated/ios/swift/MixAudioMode.swift | 48 + .../generated/ios/swift/NativeDrmParams.swift | 193 +++ .../ios/swift/NativeExternalSubtitle.swift | 44 + .../ios/swift/NativeVideoConfig.swift | 136 ++ .../ios/swift/OnGetLicensePayload.swift | 44 + nitrogen/generated/ios/swift/ResizeMode.swift | 48 + nitrogen/generated/ios/swift/Resolution.swift | 34 + nitrogen/generated/ios/swift/SourceType.swift | 40 + .../generated/ios/swift/SubtitleType.swift | 52 + .../generated/ios/swift/SurfaceType.swift | 40 + nitrogen/generated/ios/swift/TextTrack.swift | 57 + .../generated/ios/swift/TimedMetadata.swift | 35 + .../ios/swift/TimedMetadataObject.swift | 34 + .../swift/Variant_NullType_TextTrack.swift | 18 + ...pe__any_HybridVideoPlayerSourceSpec_.swift | 18 + .../ios/swift/VideoInformation.swift | 64 + .../ios/swift/VideoOrientation.swift | 60 + .../ios/swift/VideoPlayerStatus.swift | 48 + nitrogen/generated/ios/swift/onLoadData.swift | 49 + .../generated/ios/swift/onLoadStartData.swift | 41 + .../ios/swift/onPlaybackStateChangeData.swift | 34 + .../generated/ios/swift/onProgressData.swift | 34 + .../ios/swift/onVolumeChangeData.swift | 34 + .../generated/shared/c++/BandwidthData.hpp | 91 ++ .../generated/shared/c++/BufferConfig.hpp | 128 ++ .../shared/c++/CustomVideoMetadata.hpp | 100 ++ .../c++/HybridVideoPlayerEventEmitterSpec.cpp | 40 + .../c++/HybridVideoPlayerEventEmitterSpec.hpp | 115 ++ .../c++/HybridVideoPlayerFactorySpec.cpp | 21 + .../c++/HybridVideoPlayerFactorySpec.hpp | 67 + .../HybridVideoPlayerSourceFactorySpec.cpp | 22 + .../HybridVideoPlayerSourceFactorySpec.hpp | 69 ++ .../c++/HybridVideoPlayerSourceSpec.cpp | 23 + .../c++/HybridVideoPlayerSourceSpec.hpp | 69 ++ .../shared/c++/HybridVideoPlayerSpec.cpp | 56 + .../shared/c++/HybridVideoPlayerSpec.hpp | 118 ++ .../HybridVideoViewViewManagerFactorySpec.cpp | 21 + .../HybridVideoViewViewManagerFactorySpec.hpp | 64 + .../c++/HybridVideoViewViewManagerSpec.cpp | 46 + .../c++/HybridVideoViewViewManagerSpec.hpp | 99 ++ .../shared/c++/IgnoreSilentSwitchMode.hpp | 80 ++ .../shared/c++/ListenerSubscription.hpp | 83 ++ .../shared/c++/LivePlaybackParams.hpp | 99 ++ .../generated/shared/c++/MixAudioMode.hpp | 84 ++ .../generated/shared/c++/NativeDrmParams.hpp | 113 ++ .../shared/c++/NativeExternalSubtitle.hpp | 97 ++ .../shared/c++/NativeVideoConfig.hpp | 121 ++ .../shared/c++/OnGetLicensePayload.hpp | 95 ++ nitrogen/generated/shared/c++/ResizeMode.hpp | 84 ++ nitrogen/generated/shared/c++/Resolution.hpp | 87 ++ nitrogen/generated/shared/c++/SourceType.hpp | 76 ++ .../generated/shared/c++/SubtitleType.hpp | 88 ++ nitrogen/generated/shared/c++/SurfaceType.hpp | 76 ++ nitrogen/generated/shared/c++/TextTrack.hpp | 96 ++ .../generated/shared/c++/TimedMetadata.hpp | 85 ++ .../shared/c++/TimedMetadataObject.hpp | 87 ++ .../generated/shared/c++/VideoInformation.hpp | 112 ++ .../generated/shared/c++/VideoOrientation.hpp | 96 ++ .../shared/c++/VideoPlayerStatus.hpp | 84 ++ nitrogen/generated/shared/c++/onLoadData.hpp | 100 ++ .../generated/shared/c++/onLoadStartData.hpp | 92 ++ .../shared/c++/onPlaybackStateChangeData.hpp | 87 ++ .../generated/shared/c++/onProgressData.hpp | 87 ++ .../shared/c++/onVolumeChangeData.hpp | 87 ++ package.json | 158 +++ react-native.config.js | 19 + src/core/VideoPlayer.ts | 336 +++++ src/core/VideoPlayer.web.ts | 251 ++++ src/core/events/VideoPlayerEvents.native.ts | 44 + src/core/events/VideoPlayerEvents.ts | 3 + src/core/events/VideoPlayerEvents.web.ts | 16 + src/core/events/VideoPlayerEventsBase.ts | 108 ++ src/core/hooks/useEvent.ts | 24 + src/core/hooks/useManagedInstance.ts | 96 ++ src/core/hooks/useVideoPlayer.ts | 73 ++ src/core/types/BufferConfig.ts | 103 ++ src/core/types/DrmParams.ts | 65 + src/core/types/EventEmitter.ts | 73 ++ src/core/types/Events.ts | 282 +++++ src/core/types/IgnoreSilentSwitchMode.ts | 1 + src/core/types/MixAudioMode.ts | 1 + src/core/types/ResizeMode.ts | 9 + src/core/types/TextTrack.ts | 22 + src/core/types/Utils.ts | 1 + src/core/types/VideoConfig.ts | 155 +++ src/core/types/VideoError.ts | 174 +++ src/core/types/VideoInformation.ts | 45 + src/core/types/VideoOrientation.ts | 8 + src/core/types/VideoPlayerBase.ts | 174 +++ src/core/types/VideoPlayerSourceBase.ts | 19 + src/core/types/VideoPlayerStatus.ts | 8 + src/core/utils/playerFactory.ts | 34 + src/core/utils/sourceFactory.ts | 163 +++ src/core/utils/sourceUtils.ts | 11 + src/core/video-view/NativeVideoView.tsx | 18 + src/core/video-view/VideoView.tsx | 324 +++++ src/core/video-view/VideoView.web.tsx | 143 +++ src/core/video-view/VideoViewProps.ts | 87 ++ src/core/web/MediaSession.ts | 110 ++ src/core/web/WebEventEmitter.ts | 362 ++++++ src/expo-plugins/@types.ts | 37 + src/expo-plugins/getPackageInfo.ts | 23 + src/expo-plugins/withAndroidExtensions.ts | 46 + .../withAndroidNotificationControls.ts | 43 + .../withAndroidPictureInPicture.ts | 31 + src/expo-plugins/withBackgroundAudio.ts | 26 + src/expo-plugins/withReactNativeVideo.ts | 32 + src/expo-plugins/writeToPodfile.ts | 74 ++ src/index.tsx | 27 + src/spec/fabric/VideoViewNativeComponent.ts | 17 + src/spec/nitro/VideoPlayer.nitro.ts | 55 + .../nitro/VideoPlayerEventEmitter.nitro.ts | 207 ++++ src/spec/nitro/VideoPlayerSource.nitro.ts | 18 + src/spec/nitro/VideoViewViewManager.nitro.ts | 92 ++ tsconfig.build.json | 3 + tsconfig.json | 10 + tsconfig.tsbuildinfo | 1 + tsconfig.web.json | 7 + 762 files changed, 41571 insertions(+) create mode 100644 .eslintrc.js create mode 100644 .watchmanconfig create mode 100644 ReactNativeVideo.podspec create mode 100644 android/CMakeLists.txt create mode 100644 android/build.gradle create mode 100644 android/fix-prefab.gradle create mode 100644 android/gradle.properties create mode 100644 android/src/main/AndroidManifest.xml create mode 100644 android/src/main/AndroidManifestNew.xml create mode 100644 android/src/main/cpp/cpp-adapter.cpp create mode 100644 android/src/main/java/com/twg/video/core/AudioFocusManager.kt create mode 100644 android/src/main/java/com/twg/video/core/VideoError.kt create mode 100644 android/src/main/java/com/twg/video/core/VideoManager.kt create mode 100644 android/src/main/java/com/twg/video/core/extensions/ResizeMode+AspectRatioFrameLayout.kt create mode 100644 android/src/main/java/com/twg/video/core/extensions/SubtitleType+toString.kt create mode 100644 android/src/main/java/com/twg/video/core/extensions/VideoPlaybackService+ServiceManagment.kt create mode 100644 android/src/main/java/com/twg/video/core/fragments/FullscreenVideoFragment.kt create mode 100644 android/src/main/java/com/twg/video/core/fragments/PictureInPictureHelperFragment.kt create mode 100644 android/src/main/java/com/twg/video/core/player/DRMManagerSpec.kt create mode 100644 android/src/main/java/com/twg/video/core/player/DataSourceFactoryUtils.kt create mode 100644 android/src/main/java/com/twg/video/core/player/MediaItemUtils.kt create mode 100644 android/src/main/java/com/twg/video/core/player/MediaSourceUtils.kt create mode 100644 android/src/main/java/com/twg/video/core/player/OnAudioFocusChangedListener.kt create mode 100644 android/src/main/java/com/twg/video/core/plugins/PluginsRegistry.kt create mode 100644 android/src/main/java/com/twg/video/core/plugins/ReactNativeVideoPlugin.kt create mode 100644 android/src/main/java/com/twg/video/core/recivers/AudioBecomingNoisyReceiver.kt create mode 100644 android/src/main/java/com/twg/video/core/services/playback/CustomMediaNotificationProvider.kt create mode 100644 android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackCallback.kt create mode 100644 android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackService.kt create mode 100644 android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackServiceConnection.kt create mode 100644 android/src/main/java/com/twg/video/core/utils/PictureInPictureUtils.kt create mode 100644 android/src/main/java/com/twg/video/core/utils/SmallVideoPlayerOptimizer.kt create mode 100644 android/src/main/java/com/twg/video/core/utils/SourceLoader.kt create mode 100644 android/src/main/java/com/twg/video/core/utils/TextTrackUtils.kt create mode 100644 android/src/main/java/com/twg/video/core/utils/Threading.kt create mode 100644 android/src/main/java/com/twg/video/core/utils/VideoFileHelper.kt create mode 100644 android/src/main/java/com/twg/video/core/utils/VideoInformationUtils.kt create mode 100644 android/src/main/java/com/twg/video/core/utils/VideoOrientationUtils.kt create mode 100644 android/src/main/java/com/twg/video/hybrids/videoplayer/HybridVideoPlayer.kt create mode 100644 android/src/main/java/com/twg/video/hybrids/videoplayer/HybridVideoPlayerFactory.kt create mode 100644 android/src/main/java/com/twg/video/hybrids/videoplayereventemitter/HybridVideoPlayerEventEmitter.kt create mode 100644 android/src/main/java/com/twg/video/hybrids/videoplayersource/HybridVideoPlayerSource.kt create mode 100644 android/src/main/java/com/twg/video/hybrids/videoplayersource/HybridVideoPlayerSourceFactory.kt create mode 100644 android/src/main/java/com/twg/video/hybrids/videoviewviewmanager/HybridVideoViewViewManager.kt create mode 100644 android/src/main/java/com/twg/video/hybrids/videoviewviewmanager/HybridVideoViewViewManagerFactory.kt create mode 100644 android/src/main/java/com/twg/video/react/VideoPackage.kt create mode 100644 android/src/main/java/com/twg/video/react/VideoViewViewManager.kt create mode 100644 android/src/main/java/com/twg/video/view/VideoView.kt create mode 100644 android/src/main/res/layout/player_view_surface.xml create mode 100644 android/src/main/res/layout/player_view_texture.xml create mode 100644 android/src/paper/java/com/facebook/react/viewmanagers/RNCVideoViewManagerDelegate.java create mode 100644 android/src/paper/java/com/facebook/react/viewmanagers/RNCVideoViewManagerInterface.java create mode 100644 android/src/stubs/dash/androidx/media3/exoplayer/dash/DashMediaSource.kt create mode 100644 android/src/stubs/dash/androidx/media3/exoplayer/dash/DashUtil.kt create mode 100644 android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/AdaptationSet.kt create mode 100644 android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/DashManifest.kt create mode 100644 android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/Period.kt create mode 100644 android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/Representation.kt create mode 100644 android/src/stubs/hls/androidx/media3/exoplayer/hls/HlsMediaSource.kt create mode 100644 app.plugin.js create mode 100644 babel.config.js create mode 100644 ios/Video-Bridging-Header.h create mode 100644 ios/core/Extensions/AVAsset+estimatedMemoryUsage.swift create mode 100644 ios/core/Extensions/AVAssetTrack+orientation.swift create mode 100644 ios/core/Extensions/AVMetadataItem+make.swift create mode 100644 ios/core/Extensions/AVPlayerItem+externalSubtitles.swift create mode 100644 ios/core/Extensions/AVPlayerItem+getBufferedDurration.swift create mode 100644 ios/core/Extensions/AVPlayerItem+setBufferConfig.swift create mode 100644 ios/core/Extensions/AVPlayerViewController+Fullscreen.swift create mode 100644 ios/core/Extensions/AVPlayerViewController+PictureInPicture.swift create mode 100644 ios/core/Extensions/AVURLAsset+getAssetInformation.swift create mode 100644 ios/core/Extensions/NSObject+PerformIfResponds.swift create mode 100644 ios/core/Extensions/ResizeMode+VideoGravity.swift create mode 100644 ios/core/HLSSubtitleInjector.swift create mode 100644 ios/core/NowPlayingInfoCenterManager.swift create mode 100644 ios/core/Plugins/PluginsRegistry.swift create mode 100644 ios/core/Plugins/ReactNativeVideoPlugin.swift create mode 100644 ios/core/Spec/DRMManagerSpec.swift create mode 100644 ios/core/Spec/NativeVideoPlayerSourceSpec.swift create mode 100644 ios/core/Spec/NativeVideoPlayerSpec.swift create mode 100644 ios/core/Utils/ExternalSubtitlesUtils.swift create mode 100644 ios/core/Utils/HLSManifestParser.swift create mode 100644 ios/core/Utils/Weak.swift create mode 100644 ios/core/VideoError.swift create mode 100644 ios/core/VideoFileHelper.swift create mode 100644 ios/core/VideoManager.swift create mode 100644 ios/core/VideoPlayerObserver.swift create mode 100644 ios/hybrids/VideoPlayer/HybridVideoPlayer+Events.swift create mode 100644 ios/hybrids/VideoPlayer/HybridVideoPlayer.swift create mode 100644 ios/hybrids/VideoPlayer/HybridVideoPlayerFactory.swift create mode 100644 ios/hybrids/VideoPlayerEmitter/HybridVideoPlayerEventEmitter.swift create mode 100644 ios/hybrids/VideoPlayerSource/HybridVideoPlayerSource.swift create mode 100644 ios/hybrids/VideoPlayerSource/HybridVideoPlayerSourceFactory.swift create mode 100644 ios/hybrids/VideoPlayerSource/SourceLoader.swift create mode 100644 ios/hybrids/VideoViewViewManager/HybridVideoViewViewManager.swift create mode 100644 ios/hybrids/VideoViewViewManager/HybridVideoViewViewManagerFactory.swift create mode 100644 ios/view/VideoComponentView.swift create mode 100644 ios/view/VideoComponentViewObserver.swift create mode 100644 ios/view/fabric/RCTVideoViewComponentView.h create mode 100644 ios/view/fabric/RCTVideoViewComponentView.mm create mode 100644 ios/view/fabric/RCTVideoViewViewManager.mm create mode 100644 ios/view/paper/RCTVideoViewComponentView.h create mode 100644 ios/view/paper/RCTVideoViewComponentView.mm create mode 100644 ios/view/paper/RCTVideoViewViewManager.m create mode 100644 lib/commonjs/core/VideoPlayer.js create mode 100644 lib/commonjs/core/VideoPlayer.js.map create mode 100644 lib/commonjs/core/VideoPlayer.web.js create mode 100644 lib/commonjs/core/VideoPlayer.web.js.map create mode 100644 lib/commonjs/core/events/VideoPlayerEvents.js create mode 100644 lib/commonjs/core/events/VideoPlayerEvents.js.map create mode 100644 lib/commonjs/core/events/VideoPlayerEvents.native.js create mode 100644 lib/commonjs/core/events/VideoPlayerEvents.native.js.map create mode 100644 lib/commonjs/core/events/VideoPlayerEvents.web.js create mode 100644 lib/commonjs/core/events/VideoPlayerEvents.web.js.map create mode 100644 lib/commonjs/core/events/VideoPlayerEventsBase.js create mode 100644 lib/commonjs/core/events/VideoPlayerEventsBase.js.map create mode 100644 lib/commonjs/core/hooks/useEvent.js create mode 100644 lib/commonjs/core/hooks/useEvent.js.map create mode 100644 lib/commonjs/core/hooks/useManagedInstance.js create mode 100644 lib/commonjs/core/hooks/useManagedInstance.js.map create mode 100644 lib/commonjs/core/hooks/useVideoPlayer.js create mode 100644 lib/commonjs/core/hooks/useVideoPlayer.js.map create mode 100644 lib/commonjs/core/types/BufferConfig.js create mode 100644 lib/commonjs/core/types/BufferConfig.js.map create mode 100644 lib/commonjs/core/types/DrmParams.js create mode 100644 lib/commonjs/core/types/DrmParams.js.map create mode 100644 lib/commonjs/core/types/EventEmitter.js create mode 100644 lib/commonjs/core/types/EventEmitter.js.map create mode 100644 lib/commonjs/core/types/Events.js create mode 100644 lib/commonjs/core/types/Events.js.map create mode 100644 lib/commonjs/core/types/IgnoreSilentSwitchMode.js create mode 100644 lib/commonjs/core/types/IgnoreSilentSwitchMode.js.map create mode 100644 lib/commonjs/core/types/MixAudioMode.js create mode 100644 lib/commonjs/core/types/MixAudioMode.js.map create mode 100644 lib/commonjs/core/types/ResizeMode.js create mode 100644 lib/commonjs/core/types/ResizeMode.js.map create mode 100644 lib/commonjs/core/types/TextTrack.js create mode 100644 lib/commonjs/core/types/TextTrack.js.map create mode 100644 lib/commonjs/core/types/Utils.js create mode 100644 lib/commonjs/core/types/Utils.js.map create mode 100644 lib/commonjs/core/types/VideoConfig.js create mode 100644 lib/commonjs/core/types/VideoConfig.js.map create mode 100644 lib/commonjs/core/types/VideoError.js create mode 100644 lib/commonjs/core/types/VideoError.js.map create mode 100644 lib/commonjs/core/types/VideoInformation.js create mode 100644 lib/commonjs/core/types/VideoInformation.js.map create mode 100644 lib/commonjs/core/types/VideoOrientation.js create mode 100644 lib/commonjs/core/types/VideoOrientation.js.map create mode 100644 lib/commonjs/core/types/VideoPlayerBase.js create mode 100644 lib/commonjs/core/types/VideoPlayerBase.js.map create mode 100644 lib/commonjs/core/types/VideoPlayerSourceBase.js create mode 100644 lib/commonjs/core/types/VideoPlayerSourceBase.js.map create mode 100644 lib/commonjs/core/types/VideoPlayerStatus.js create mode 100644 lib/commonjs/core/types/VideoPlayerStatus.js.map create mode 100644 lib/commonjs/core/utils/playerFactory.js create mode 100644 lib/commonjs/core/utils/playerFactory.js.map create mode 100644 lib/commonjs/core/utils/sourceFactory.js create mode 100644 lib/commonjs/core/utils/sourceFactory.js.map create mode 100644 lib/commonjs/core/utils/sourceUtils.js create mode 100644 lib/commonjs/core/utils/sourceUtils.js.map create mode 100644 lib/commonjs/core/video-view/NativeVideoView.js create mode 100644 lib/commonjs/core/video-view/NativeVideoView.js.map create mode 100644 lib/commonjs/core/video-view/VideoView.js create mode 100644 lib/commonjs/core/video-view/VideoView.js.map create mode 100644 lib/commonjs/core/video-view/VideoView.web.js create mode 100644 lib/commonjs/core/video-view/VideoView.web.js.map create mode 100644 lib/commonjs/core/video-view/VideoViewProps.js create mode 100644 lib/commonjs/core/video-view/VideoViewProps.js.map create mode 100644 lib/commonjs/core/web/MediaSession.js create mode 100644 lib/commonjs/core/web/MediaSession.js.map create mode 100644 lib/commonjs/core/web/WebEventEmitter.js create mode 100644 lib/commonjs/core/web/WebEventEmitter.js.map create mode 100644 lib/commonjs/expo-plugins/@types.js create mode 100644 lib/commonjs/expo-plugins/@types.js.map create mode 100644 lib/commonjs/expo-plugins/getPackageInfo.js create mode 100644 lib/commonjs/expo-plugins/getPackageInfo.js.map create mode 100644 lib/commonjs/expo-plugins/withAndroidExtensions.js create mode 100644 lib/commonjs/expo-plugins/withAndroidExtensions.js.map create mode 100644 lib/commonjs/expo-plugins/withAndroidNotificationControls.js create mode 100644 lib/commonjs/expo-plugins/withAndroidNotificationControls.js.map create mode 100644 lib/commonjs/expo-plugins/withAndroidPictureInPicture.js create mode 100644 lib/commonjs/expo-plugins/withAndroidPictureInPicture.js.map create mode 100644 lib/commonjs/expo-plugins/withBackgroundAudio.js create mode 100644 lib/commonjs/expo-plugins/withBackgroundAudio.js.map create mode 100644 lib/commonjs/expo-plugins/withReactNativeVideo.js create mode 100644 lib/commonjs/expo-plugins/withReactNativeVideo.js.map create mode 100644 lib/commonjs/expo-plugins/writeToPodfile.js create mode 100644 lib/commonjs/expo-plugins/writeToPodfile.js.map create mode 100644 lib/commonjs/index.js create mode 100644 lib/commonjs/index.js.map create mode 100644 lib/commonjs/package.json create mode 100644 lib/commonjs/spec/fabric/VideoViewNativeComponent.ts create mode 100644 lib/commonjs/spec/nitro/VideoPlayer.nitro.js create mode 100644 lib/commonjs/spec/nitro/VideoPlayer.nitro.js.map create mode 100644 lib/commonjs/spec/nitro/VideoPlayerEventEmitter.nitro.js create mode 100644 lib/commonjs/spec/nitro/VideoPlayerEventEmitter.nitro.js.map create mode 100644 lib/commonjs/spec/nitro/VideoPlayerSource.nitro.js create mode 100644 lib/commonjs/spec/nitro/VideoPlayerSource.nitro.js.map create mode 100644 lib/commonjs/spec/nitro/VideoViewViewManager.nitro.js create mode 100644 lib/commonjs/spec/nitro/VideoViewViewManager.nitro.js.map create mode 100644 lib/module/core/VideoPlayer.js create mode 100644 lib/module/core/VideoPlayer.js.map create mode 100644 lib/module/core/VideoPlayer.web.js create mode 100644 lib/module/core/VideoPlayer.web.js.map create mode 100644 lib/module/core/events/VideoPlayerEvents.js create mode 100644 lib/module/core/events/VideoPlayerEvents.js.map create mode 100644 lib/module/core/events/VideoPlayerEvents.native.js create mode 100644 lib/module/core/events/VideoPlayerEvents.native.js.map create mode 100644 lib/module/core/events/VideoPlayerEvents.web.js create mode 100644 lib/module/core/events/VideoPlayerEvents.web.js.map create mode 100644 lib/module/core/events/VideoPlayerEventsBase.js create mode 100644 lib/module/core/events/VideoPlayerEventsBase.js.map create mode 100644 lib/module/core/hooks/useEvent.js create mode 100644 lib/module/core/hooks/useEvent.js.map create mode 100644 lib/module/core/hooks/useManagedInstance.js create mode 100644 lib/module/core/hooks/useManagedInstance.js.map create mode 100644 lib/module/core/hooks/useVideoPlayer.js create mode 100644 lib/module/core/hooks/useVideoPlayer.js.map create mode 100644 lib/module/core/types/BufferConfig.js create mode 100644 lib/module/core/types/BufferConfig.js.map create mode 100644 lib/module/core/types/DrmParams.js create mode 100644 lib/module/core/types/DrmParams.js.map create mode 100644 lib/module/core/types/EventEmitter.js create mode 100644 lib/module/core/types/EventEmitter.js.map create mode 100644 lib/module/core/types/Events.js create mode 100644 lib/module/core/types/Events.js.map create mode 100644 lib/module/core/types/IgnoreSilentSwitchMode.js create mode 100644 lib/module/core/types/IgnoreSilentSwitchMode.js.map create mode 100644 lib/module/core/types/MixAudioMode.js create mode 100644 lib/module/core/types/MixAudioMode.js.map create mode 100644 lib/module/core/types/ResizeMode.js create mode 100644 lib/module/core/types/ResizeMode.js.map create mode 100644 lib/module/core/types/TextTrack.js create mode 100644 lib/module/core/types/TextTrack.js.map create mode 100644 lib/module/core/types/Utils.js create mode 100644 lib/module/core/types/Utils.js.map create mode 100644 lib/module/core/types/VideoConfig.js create mode 100644 lib/module/core/types/VideoConfig.js.map create mode 100644 lib/module/core/types/VideoError.js create mode 100644 lib/module/core/types/VideoError.js.map create mode 100644 lib/module/core/types/VideoInformation.js create mode 100644 lib/module/core/types/VideoInformation.js.map create mode 100644 lib/module/core/types/VideoOrientation.js create mode 100644 lib/module/core/types/VideoOrientation.js.map create mode 100644 lib/module/core/types/VideoPlayerBase.js create mode 100644 lib/module/core/types/VideoPlayerBase.js.map create mode 100644 lib/module/core/types/VideoPlayerSourceBase.js create mode 100644 lib/module/core/types/VideoPlayerSourceBase.js.map create mode 100644 lib/module/core/types/VideoPlayerStatus.js create mode 100644 lib/module/core/types/VideoPlayerStatus.js.map create mode 100644 lib/module/core/utils/playerFactory.js create mode 100644 lib/module/core/utils/playerFactory.js.map create mode 100644 lib/module/core/utils/sourceFactory.js create mode 100644 lib/module/core/utils/sourceFactory.js.map create mode 100644 lib/module/core/utils/sourceUtils.js create mode 100644 lib/module/core/utils/sourceUtils.js.map create mode 100644 lib/module/core/video-view/NativeVideoView.js create mode 100644 lib/module/core/video-view/NativeVideoView.js.map create mode 100644 lib/module/core/video-view/VideoView.js create mode 100644 lib/module/core/video-view/VideoView.js.map create mode 100644 lib/module/core/video-view/VideoView.web.js create mode 100644 lib/module/core/video-view/VideoView.web.js.map create mode 100644 lib/module/core/video-view/VideoViewProps.js create mode 100644 lib/module/core/video-view/VideoViewProps.js.map create mode 100644 lib/module/core/web/MediaSession.js create mode 100644 lib/module/core/web/MediaSession.js.map create mode 100644 lib/module/core/web/WebEventEmitter.js create mode 100644 lib/module/core/web/WebEventEmitter.js.map create mode 100644 lib/module/expo-plugins/@types.js create mode 100644 lib/module/expo-plugins/@types.js.map create mode 100644 lib/module/expo-plugins/getPackageInfo.js create mode 100644 lib/module/expo-plugins/getPackageInfo.js.map create mode 100644 lib/module/expo-plugins/withAndroidExtensions.js create mode 100644 lib/module/expo-plugins/withAndroidExtensions.js.map create mode 100644 lib/module/expo-plugins/withAndroidNotificationControls.js create mode 100644 lib/module/expo-plugins/withAndroidNotificationControls.js.map create mode 100644 lib/module/expo-plugins/withAndroidPictureInPicture.js create mode 100644 lib/module/expo-plugins/withAndroidPictureInPicture.js.map create mode 100644 lib/module/expo-plugins/withBackgroundAudio.js create mode 100644 lib/module/expo-plugins/withBackgroundAudio.js.map create mode 100644 lib/module/expo-plugins/withReactNativeVideo.js create mode 100644 lib/module/expo-plugins/withReactNativeVideo.js.map create mode 100644 lib/module/expo-plugins/writeToPodfile.js create mode 100644 lib/module/expo-plugins/writeToPodfile.js.map create mode 100644 lib/module/index.js create mode 100644 lib/module/index.js.map create mode 100644 lib/module/package.json create mode 100644 lib/module/spec/fabric/VideoViewNativeComponent.ts create mode 100644 lib/module/spec/nitro/VideoPlayer.nitro.js create mode 100644 lib/module/spec/nitro/VideoPlayer.nitro.js.map create mode 100644 lib/module/spec/nitro/VideoPlayerEventEmitter.nitro.js create mode 100644 lib/module/spec/nitro/VideoPlayerEventEmitter.nitro.js.map create mode 100644 lib/module/spec/nitro/VideoPlayerSource.nitro.js create mode 100644 lib/module/spec/nitro/VideoPlayerSource.nitro.js.map create mode 100644 lib/module/spec/nitro/VideoViewViewManager.nitro.js create mode 100644 lib/module/spec/nitro/VideoViewViewManager.nitro.js.map create mode 100644 lib/typescript/commonjs/package.json create mode 100644 lib/typescript/commonjs/src/core/VideoPlayer.d.ts create mode 100644 lib/typescript/commonjs/src/core/VideoPlayer.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/events/VideoPlayerEvents.d.ts create mode 100644 lib/typescript/commonjs/src/core/events/VideoPlayerEvents.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/events/VideoPlayerEvents.native.d.ts create mode 100644 lib/typescript/commonjs/src/core/events/VideoPlayerEvents.native.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/events/VideoPlayerEventsBase.d.ts create mode 100644 lib/typescript/commonjs/src/core/events/VideoPlayerEventsBase.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/hooks/useEvent.d.ts create mode 100644 lib/typescript/commonjs/src/core/hooks/useEvent.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/hooks/useManagedInstance.d.ts create mode 100644 lib/typescript/commonjs/src/core/hooks/useManagedInstance.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/hooks/useVideoPlayer.d.ts create mode 100644 lib/typescript/commonjs/src/core/hooks/useVideoPlayer.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/BufferConfig.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/BufferConfig.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/DrmParams.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/DrmParams.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/EventEmitter.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/EventEmitter.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/Events.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/Events.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/IgnoreSilentSwitchMode.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/IgnoreSilentSwitchMode.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/MixAudioMode.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/MixAudioMode.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/ResizeMode.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/ResizeMode.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/TextTrack.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/TextTrack.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/Utils.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/Utils.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/VideoConfig.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/VideoConfig.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/VideoError.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/VideoError.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/VideoInformation.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/VideoInformation.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/VideoOrientation.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/VideoOrientation.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/VideoPlayerBase.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/VideoPlayerBase.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/VideoPlayerSourceBase.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/VideoPlayerSourceBase.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/types/VideoPlayerStatus.d.ts create mode 100644 lib/typescript/commonjs/src/core/types/VideoPlayerStatus.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/utils/playerFactory.d.ts create mode 100644 lib/typescript/commonjs/src/core/utils/playerFactory.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/utils/sourceFactory.d.ts create mode 100644 lib/typescript/commonjs/src/core/utils/sourceFactory.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/utils/sourceUtils.d.ts create mode 100644 lib/typescript/commonjs/src/core/utils/sourceUtils.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/video-view/NativeVideoView.d.ts create mode 100644 lib/typescript/commonjs/src/core/video-view/NativeVideoView.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/video-view/VideoView.d.ts create mode 100644 lib/typescript/commonjs/src/core/video-view/VideoView.d.ts.map create mode 100644 lib/typescript/commonjs/src/core/video-view/VideoViewProps.d.ts create mode 100644 lib/typescript/commonjs/src/core/video-view/VideoViewProps.d.ts.map create mode 100644 lib/typescript/commonjs/src/expo-plugins/@types.d.ts create mode 100644 lib/typescript/commonjs/src/expo-plugins/@types.d.ts.map create mode 100644 lib/typescript/commonjs/src/expo-plugins/getPackageInfo.d.ts create mode 100644 lib/typescript/commonjs/src/expo-plugins/getPackageInfo.d.ts.map create mode 100644 lib/typescript/commonjs/src/expo-plugins/withAndroidExtensions.d.ts create mode 100644 lib/typescript/commonjs/src/expo-plugins/withAndroidExtensions.d.ts.map create mode 100644 lib/typescript/commonjs/src/expo-plugins/withAndroidNotificationControls.d.ts create mode 100644 lib/typescript/commonjs/src/expo-plugins/withAndroidNotificationControls.d.ts.map create mode 100644 lib/typescript/commonjs/src/expo-plugins/withAndroidPictureInPicture.d.ts create mode 100644 lib/typescript/commonjs/src/expo-plugins/withAndroidPictureInPicture.d.ts.map create mode 100644 lib/typescript/commonjs/src/expo-plugins/withBackgroundAudio.d.ts create mode 100644 lib/typescript/commonjs/src/expo-plugins/withBackgroundAudio.d.ts.map create mode 100644 lib/typescript/commonjs/src/expo-plugins/withReactNativeVideo.d.ts create mode 100644 lib/typescript/commonjs/src/expo-plugins/withReactNativeVideo.d.ts.map create mode 100644 lib/typescript/commonjs/src/expo-plugins/writeToPodfile.d.ts create mode 100644 lib/typescript/commonjs/src/expo-plugins/writeToPodfile.d.ts.map create mode 100644 lib/typescript/commonjs/src/index.d.ts create mode 100644 lib/typescript/commonjs/src/index.d.ts.map create mode 100644 lib/typescript/commonjs/src/spec/fabric/VideoViewNativeComponent.d.ts create mode 100644 lib/typescript/commonjs/src/spec/fabric/VideoViewNativeComponent.d.ts.map create mode 100644 lib/typescript/commonjs/src/spec/nitro/VideoPlayer.nitro.d.ts create mode 100644 lib/typescript/commonjs/src/spec/nitro/VideoPlayer.nitro.d.ts.map create mode 100644 lib/typescript/commonjs/src/spec/nitro/VideoPlayerEventEmitter.nitro.d.ts create mode 100644 lib/typescript/commonjs/src/spec/nitro/VideoPlayerEventEmitter.nitro.d.ts.map create mode 100644 lib/typescript/commonjs/src/spec/nitro/VideoPlayerSource.nitro.d.ts create mode 100644 lib/typescript/commonjs/src/spec/nitro/VideoPlayerSource.nitro.d.ts.map create mode 100644 lib/typescript/commonjs/src/spec/nitro/VideoViewViewManager.nitro.d.ts create mode 100644 lib/typescript/commonjs/src/spec/nitro/VideoViewViewManager.nitro.d.ts.map create mode 100644 lib/typescript/module/package.json create mode 100644 lib/typescript/module/src/core/VideoPlayer.d.ts create mode 100644 lib/typescript/module/src/core/VideoPlayer.d.ts.map create mode 100644 lib/typescript/module/src/core/events/VideoPlayerEvents.d.ts create mode 100644 lib/typescript/module/src/core/events/VideoPlayerEvents.d.ts.map create mode 100644 lib/typescript/module/src/core/events/VideoPlayerEvents.native.d.ts create mode 100644 lib/typescript/module/src/core/events/VideoPlayerEvents.native.d.ts.map create mode 100644 lib/typescript/module/src/core/events/VideoPlayerEventsBase.d.ts create mode 100644 lib/typescript/module/src/core/events/VideoPlayerEventsBase.d.ts.map create mode 100644 lib/typescript/module/src/core/hooks/useEvent.d.ts create mode 100644 lib/typescript/module/src/core/hooks/useEvent.d.ts.map create mode 100644 lib/typescript/module/src/core/hooks/useManagedInstance.d.ts create mode 100644 lib/typescript/module/src/core/hooks/useManagedInstance.d.ts.map create mode 100644 lib/typescript/module/src/core/hooks/useVideoPlayer.d.ts create mode 100644 lib/typescript/module/src/core/hooks/useVideoPlayer.d.ts.map create mode 100644 lib/typescript/module/src/core/types/BufferConfig.d.ts create mode 100644 lib/typescript/module/src/core/types/BufferConfig.d.ts.map create mode 100644 lib/typescript/module/src/core/types/DrmParams.d.ts create mode 100644 lib/typescript/module/src/core/types/DrmParams.d.ts.map create mode 100644 lib/typescript/module/src/core/types/EventEmitter.d.ts create mode 100644 lib/typescript/module/src/core/types/EventEmitter.d.ts.map create mode 100644 lib/typescript/module/src/core/types/Events.d.ts create mode 100644 lib/typescript/module/src/core/types/Events.d.ts.map create mode 100644 lib/typescript/module/src/core/types/IgnoreSilentSwitchMode.d.ts create mode 100644 lib/typescript/module/src/core/types/IgnoreSilentSwitchMode.d.ts.map create mode 100644 lib/typescript/module/src/core/types/MixAudioMode.d.ts create mode 100644 lib/typescript/module/src/core/types/MixAudioMode.d.ts.map create mode 100644 lib/typescript/module/src/core/types/ResizeMode.d.ts create mode 100644 lib/typescript/module/src/core/types/ResizeMode.d.ts.map create mode 100644 lib/typescript/module/src/core/types/TextTrack.d.ts create mode 100644 lib/typescript/module/src/core/types/TextTrack.d.ts.map create mode 100644 lib/typescript/module/src/core/types/Utils.d.ts create mode 100644 lib/typescript/module/src/core/types/Utils.d.ts.map create mode 100644 lib/typescript/module/src/core/types/VideoConfig.d.ts create mode 100644 lib/typescript/module/src/core/types/VideoConfig.d.ts.map create mode 100644 lib/typescript/module/src/core/types/VideoError.d.ts create mode 100644 lib/typescript/module/src/core/types/VideoError.d.ts.map create mode 100644 lib/typescript/module/src/core/types/VideoInformation.d.ts create mode 100644 lib/typescript/module/src/core/types/VideoInformation.d.ts.map create mode 100644 lib/typescript/module/src/core/types/VideoOrientation.d.ts create mode 100644 lib/typescript/module/src/core/types/VideoOrientation.d.ts.map create mode 100644 lib/typescript/module/src/core/types/VideoPlayerBase.d.ts create mode 100644 lib/typescript/module/src/core/types/VideoPlayerBase.d.ts.map create mode 100644 lib/typescript/module/src/core/types/VideoPlayerSourceBase.d.ts create mode 100644 lib/typescript/module/src/core/types/VideoPlayerSourceBase.d.ts.map create mode 100644 lib/typescript/module/src/core/types/VideoPlayerStatus.d.ts create mode 100644 lib/typescript/module/src/core/types/VideoPlayerStatus.d.ts.map create mode 100644 lib/typescript/module/src/core/utils/playerFactory.d.ts create mode 100644 lib/typescript/module/src/core/utils/playerFactory.d.ts.map create mode 100644 lib/typescript/module/src/core/utils/sourceFactory.d.ts create mode 100644 lib/typescript/module/src/core/utils/sourceFactory.d.ts.map create mode 100644 lib/typescript/module/src/core/utils/sourceUtils.d.ts create mode 100644 lib/typescript/module/src/core/utils/sourceUtils.d.ts.map create mode 100644 lib/typescript/module/src/core/video-view/NativeVideoView.d.ts create mode 100644 lib/typescript/module/src/core/video-view/NativeVideoView.d.ts.map create mode 100644 lib/typescript/module/src/core/video-view/VideoView.d.ts create mode 100644 lib/typescript/module/src/core/video-view/VideoView.d.ts.map create mode 100644 lib/typescript/module/src/core/video-view/VideoViewProps.d.ts create mode 100644 lib/typescript/module/src/core/video-view/VideoViewProps.d.ts.map create mode 100644 lib/typescript/module/src/expo-plugins/@types.d.ts create mode 100644 lib/typescript/module/src/expo-plugins/@types.d.ts.map create mode 100644 lib/typescript/module/src/expo-plugins/getPackageInfo.d.ts create mode 100644 lib/typescript/module/src/expo-plugins/getPackageInfo.d.ts.map create mode 100644 lib/typescript/module/src/expo-plugins/withAndroidExtensions.d.ts create mode 100644 lib/typescript/module/src/expo-plugins/withAndroidExtensions.d.ts.map create mode 100644 lib/typescript/module/src/expo-plugins/withAndroidNotificationControls.d.ts create mode 100644 lib/typescript/module/src/expo-plugins/withAndroidNotificationControls.d.ts.map create mode 100644 lib/typescript/module/src/expo-plugins/withAndroidPictureInPicture.d.ts create mode 100644 lib/typescript/module/src/expo-plugins/withAndroidPictureInPicture.d.ts.map create mode 100644 lib/typescript/module/src/expo-plugins/withBackgroundAudio.d.ts create mode 100644 lib/typescript/module/src/expo-plugins/withBackgroundAudio.d.ts.map create mode 100644 lib/typescript/module/src/expo-plugins/withReactNativeVideo.d.ts create mode 100644 lib/typescript/module/src/expo-plugins/withReactNativeVideo.d.ts.map create mode 100644 lib/typescript/module/src/expo-plugins/writeToPodfile.d.ts create mode 100644 lib/typescript/module/src/expo-plugins/writeToPodfile.d.ts.map create mode 100644 lib/typescript/module/src/index.d.ts create mode 100644 lib/typescript/module/src/index.d.ts.map create mode 100644 lib/typescript/module/src/spec/fabric/VideoViewNativeComponent.d.ts create mode 100644 lib/typescript/module/src/spec/fabric/VideoViewNativeComponent.d.ts.map create mode 100644 lib/typescript/module/src/spec/nitro/VideoPlayer.nitro.d.ts create mode 100644 lib/typescript/module/src/spec/nitro/VideoPlayer.nitro.d.ts.map create mode 100644 lib/typescript/module/src/spec/nitro/VideoPlayerEventEmitter.nitro.d.ts create mode 100644 lib/typescript/module/src/spec/nitro/VideoPlayerEventEmitter.nitro.d.ts.map create mode 100644 lib/typescript/module/src/spec/nitro/VideoPlayerSource.nitro.d.ts create mode 100644 lib/typescript/module/src/spec/nitro/VideoPlayerSource.nitro.d.ts.map create mode 100644 lib/typescript/module/src/spec/nitro/VideoViewViewManager.nitro.d.ts create mode 100644 lib/typescript/module/src/spec/nitro/VideoViewViewManager.nitro.d.ts.map create mode 100644 nitro.json create mode 100644 nitrogen/generated/.gitattributes create mode 100644 nitrogen/generated/android/ReactNativeVideo+autolinking.cmake create mode 100644 nitrogen/generated/android/ReactNativeVideo+autolinking.gradle create mode 100644 nitrogen/generated/android/ReactNativeVideoOnLoad.cpp create mode 100644 nitrogen/generated/android/ReactNativeVideoOnLoad.hpp create mode 100644 nitrogen/generated/android/c++/JBandwidthData.hpp create mode 100644 nitrogen/generated/android/c++/JBufferConfig.hpp create mode 100644 nitrogen/generated/android/c++/JCustomVideoMetadata.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__string_____OnGetLicensePayload.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void_BandwidthData.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void_TimedMetadata.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void_VideoPlayerStatus.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void_bool.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void_double.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void_onLoadData.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void_onLoadStartData.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void_onPlaybackStateChangeData.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void_onProgressData.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void_onVolumeChangeData.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void_std__optional_std__variant_nitro__NullType__TextTrack__.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void_std__vector_std__string_.hpp create mode 100644 nitrogen/generated/android/c++/JHybridVideoPlayerEventEmitterSpec.cpp create mode 100644 nitrogen/generated/android/c++/JHybridVideoPlayerEventEmitterSpec.hpp create mode 100644 nitrogen/generated/android/c++/JHybridVideoPlayerFactorySpec.cpp create mode 100644 nitrogen/generated/android/c++/JHybridVideoPlayerFactorySpec.hpp create mode 100644 nitrogen/generated/android/c++/JHybridVideoPlayerSourceFactorySpec.cpp create mode 100644 nitrogen/generated/android/c++/JHybridVideoPlayerSourceFactorySpec.hpp create mode 100644 nitrogen/generated/android/c++/JHybridVideoPlayerSourceSpec.cpp create mode 100644 nitrogen/generated/android/c++/JHybridVideoPlayerSourceSpec.hpp create mode 100644 nitrogen/generated/android/c++/JHybridVideoPlayerSpec.cpp create mode 100644 nitrogen/generated/android/c++/JHybridVideoPlayerSpec.hpp create mode 100644 nitrogen/generated/android/c++/JHybridVideoViewViewManagerFactorySpec.cpp create mode 100644 nitrogen/generated/android/c++/JHybridVideoViewViewManagerFactorySpec.hpp create mode 100644 nitrogen/generated/android/c++/JHybridVideoViewViewManagerSpec.cpp create mode 100644 nitrogen/generated/android/c++/JHybridVideoViewViewManagerSpec.hpp create mode 100644 nitrogen/generated/android/c++/JIgnoreSilentSwitchMode.hpp create mode 100644 nitrogen/generated/android/c++/JListenerSubscription.hpp create mode 100644 nitrogen/generated/android/c++/JLivePlaybackParams.hpp create mode 100644 nitrogen/generated/android/c++/JMixAudioMode.hpp create mode 100644 nitrogen/generated/android/c++/JNativeDrmParams.hpp create mode 100644 nitrogen/generated/android/c++/JNativeExternalSubtitle.hpp create mode 100644 nitrogen/generated/android/c++/JNativeVideoConfig.hpp create mode 100644 nitrogen/generated/android/c++/JOnGetLicensePayload.hpp create mode 100644 nitrogen/generated/android/c++/JResizeMode.hpp create mode 100644 nitrogen/generated/android/c++/JResolution.hpp create mode 100644 nitrogen/generated/android/c++/JSourceType.hpp create mode 100644 nitrogen/generated/android/c++/JSubtitleType.hpp create mode 100644 nitrogen/generated/android/c++/JSurfaceType.hpp create mode 100644 nitrogen/generated/android/c++/JTextTrack.hpp create mode 100644 nitrogen/generated/android/c++/JTimedMetadata.hpp create mode 100644 nitrogen/generated/android/c++/JTimedMetadataObject.hpp create mode 100644 nitrogen/generated/android/c++/JVariant_NullType_HybridVideoPlayerSourceSpec.cpp create mode 100644 nitrogen/generated/android/c++/JVariant_NullType_HybridVideoPlayerSourceSpec.hpp create mode 100644 nitrogen/generated/android/c++/JVariant_NullType_TextTrack.cpp create mode 100644 nitrogen/generated/android/c++/JVariant_NullType_TextTrack.hpp create mode 100644 nitrogen/generated/android/c++/JVideoInformation.hpp create mode 100644 nitrogen/generated/android/c++/JVideoOrientation.hpp create mode 100644 nitrogen/generated/android/c++/JVideoPlayerStatus.hpp create mode 100644 nitrogen/generated/android/c++/JonLoadData.hpp create mode 100644 nitrogen/generated/android/c++/JonLoadStartData.hpp create mode 100644 nitrogen/generated/android/c++/JonPlaybackStateChangeData.hpp create mode 100644 nitrogen/generated/android/c++/JonProgressData.hpp create mode 100644 nitrogen/generated/android/c++/JonVolumeChangeData.hpp create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/BandwidthData.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/BufferConfig.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/CustomVideoMetadata.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__string_____OnGetLicensePayload.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Func_void.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Func_void_BandwidthData.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Func_void_TimedMetadata.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Func_void_VideoPlayerStatus.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Func_void_bool.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Func_void_double.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Func_void_onLoadData.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Func_void_onLoadStartData.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Func_void_onPlaybackStateChangeData.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Func_void_onProgressData.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Func_void_onVolumeChangeData.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Func_void_std__optional_std__variant_nitro__NullType__TextTrack__.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Func_void_std__vector_std__string_.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/HybridVideoPlayerEventEmitterSpec.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/HybridVideoPlayerFactorySpec.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/HybridVideoPlayerSourceFactorySpec.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/HybridVideoPlayerSourceSpec.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/HybridVideoPlayerSpec.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/HybridVideoViewViewManagerFactorySpec.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/HybridVideoViewViewManagerSpec.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/IgnoreSilentSwitchMode.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/ListenerSubscription.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/LivePlaybackParams.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/MixAudioMode.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/NativeDrmParams.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/NativeExternalSubtitle.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/NativeVideoConfig.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/OnGetLicensePayload.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/ReactNativeVideoOnLoad.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/ResizeMode.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Resolution.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/SourceType.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/SubtitleType.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/SurfaceType.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/TextTrack.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/TimedMetadata.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/TimedMetadataObject.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Variant_NullType_HybridVideoPlayerSourceSpec.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/Variant_NullType_TextTrack.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/VideoInformation.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/VideoOrientation.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/VideoPlayerStatus.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/onLoadData.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/onLoadStartData.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/onPlaybackStateChangeData.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/onProgressData.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/video/onVolumeChangeData.kt create mode 100644 nitrogen/generated/ios/ReactNativeVideo+autolinking.rb create mode 100644 nitrogen/generated/ios/ReactNativeVideo-Swift-Cxx-Bridge.cpp create mode 100644 nitrogen/generated/ios/ReactNativeVideo-Swift-Cxx-Bridge.hpp create mode 100644 nitrogen/generated/ios/ReactNativeVideo-Swift-Cxx-Umbrella.hpp create mode 100644 nitrogen/generated/ios/ReactNativeVideoAutolinking.mm create mode 100644 nitrogen/generated/ios/ReactNativeVideoAutolinking.swift create mode 100644 nitrogen/generated/ios/c++/HybridVideoPlayerEventEmitterSpecSwift.cpp create mode 100644 nitrogen/generated/ios/c++/HybridVideoPlayerEventEmitterSpecSwift.hpp create mode 100644 nitrogen/generated/ios/c++/HybridVideoPlayerFactorySpecSwift.cpp create mode 100644 nitrogen/generated/ios/c++/HybridVideoPlayerFactorySpecSwift.hpp create mode 100644 nitrogen/generated/ios/c++/HybridVideoPlayerSourceFactorySpecSwift.cpp create mode 100644 nitrogen/generated/ios/c++/HybridVideoPlayerSourceFactorySpecSwift.hpp create mode 100644 nitrogen/generated/ios/c++/HybridVideoPlayerSourceSpecSwift.cpp create mode 100644 nitrogen/generated/ios/c++/HybridVideoPlayerSourceSpecSwift.hpp create mode 100644 nitrogen/generated/ios/c++/HybridVideoPlayerSpecSwift.cpp create mode 100644 nitrogen/generated/ios/c++/HybridVideoPlayerSpecSwift.hpp create mode 100644 nitrogen/generated/ios/c++/HybridVideoViewViewManagerFactorySpecSwift.cpp create mode 100644 nitrogen/generated/ios/c++/HybridVideoViewViewManagerFactorySpecSwift.hpp create mode 100644 nitrogen/generated/ios/c++/HybridVideoViewViewManagerSpecSwift.cpp create mode 100644 nitrogen/generated/ios/c++/HybridVideoViewViewManagerSpecSwift.hpp create mode 100644 nitrogen/generated/ios/swift/BandwidthData.swift create mode 100644 nitrogen/generated/ios/swift/BufferConfig.swift create mode 100644 nitrogen/generated/ios/swift/CustomVideoMetadata.swift create mode 100644 nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__string_____OnGetLicensePayload.swift create mode 100644 nitrogen/generated/ios/swift/Func_void.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_BandwidthData.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_TimedMetadata.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_VideoInformation.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_VideoPlayerStatus.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_bool.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_double.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_onLoadData.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_onLoadStartData.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_onPlaybackStateChangeData.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_onProgressData.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_onVolumeChangeData.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_std__optional_std__variant_nitro__NullType__TextTrack__.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_std__shared_ptr_Promise_std__string__.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_std__string.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_std__vector_std__string_.swift create mode 100644 nitrogen/generated/ios/swift/HybridVideoPlayerEventEmitterSpec.swift create mode 100644 nitrogen/generated/ios/swift/HybridVideoPlayerEventEmitterSpec_cxx.swift create mode 100644 nitrogen/generated/ios/swift/HybridVideoPlayerFactorySpec.swift create mode 100644 nitrogen/generated/ios/swift/HybridVideoPlayerFactorySpec_cxx.swift create mode 100644 nitrogen/generated/ios/swift/HybridVideoPlayerSourceFactorySpec.swift create mode 100644 nitrogen/generated/ios/swift/HybridVideoPlayerSourceFactorySpec_cxx.swift create mode 100644 nitrogen/generated/ios/swift/HybridVideoPlayerSourceSpec.swift create mode 100644 nitrogen/generated/ios/swift/HybridVideoPlayerSourceSpec_cxx.swift create mode 100644 nitrogen/generated/ios/swift/HybridVideoPlayerSpec.swift create mode 100644 nitrogen/generated/ios/swift/HybridVideoPlayerSpec_cxx.swift create mode 100644 nitrogen/generated/ios/swift/HybridVideoViewViewManagerFactorySpec.swift create mode 100644 nitrogen/generated/ios/swift/HybridVideoViewViewManagerFactorySpec_cxx.swift create mode 100644 nitrogen/generated/ios/swift/HybridVideoViewViewManagerSpec.swift create mode 100644 nitrogen/generated/ios/swift/HybridVideoViewViewManagerSpec_cxx.swift create mode 100644 nitrogen/generated/ios/swift/IgnoreSilentSwitchMode.swift create mode 100644 nitrogen/generated/ios/swift/ListenerSubscription.swift create mode 100644 nitrogen/generated/ios/swift/LivePlaybackParams.swift create mode 100644 nitrogen/generated/ios/swift/MixAudioMode.swift create mode 100644 nitrogen/generated/ios/swift/NativeDrmParams.swift create mode 100644 nitrogen/generated/ios/swift/NativeExternalSubtitle.swift create mode 100644 nitrogen/generated/ios/swift/NativeVideoConfig.swift create mode 100644 nitrogen/generated/ios/swift/OnGetLicensePayload.swift create mode 100644 nitrogen/generated/ios/swift/ResizeMode.swift create mode 100644 nitrogen/generated/ios/swift/Resolution.swift create mode 100644 nitrogen/generated/ios/swift/SourceType.swift create mode 100644 nitrogen/generated/ios/swift/SubtitleType.swift create mode 100644 nitrogen/generated/ios/swift/SurfaceType.swift create mode 100644 nitrogen/generated/ios/swift/TextTrack.swift create mode 100644 nitrogen/generated/ios/swift/TimedMetadata.swift create mode 100644 nitrogen/generated/ios/swift/TimedMetadataObject.swift create mode 100644 nitrogen/generated/ios/swift/Variant_NullType_TextTrack.swift create mode 100644 nitrogen/generated/ios/swift/Variant_NullType__any_HybridVideoPlayerSourceSpec_.swift create mode 100644 nitrogen/generated/ios/swift/VideoInformation.swift create mode 100644 nitrogen/generated/ios/swift/VideoOrientation.swift create mode 100644 nitrogen/generated/ios/swift/VideoPlayerStatus.swift create mode 100644 nitrogen/generated/ios/swift/onLoadData.swift create mode 100644 nitrogen/generated/ios/swift/onLoadStartData.swift create mode 100644 nitrogen/generated/ios/swift/onPlaybackStateChangeData.swift create mode 100644 nitrogen/generated/ios/swift/onProgressData.swift create mode 100644 nitrogen/generated/ios/swift/onVolumeChangeData.swift create mode 100644 nitrogen/generated/shared/c++/BandwidthData.hpp create mode 100644 nitrogen/generated/shared/c++/BufferConfig.hpp create mode 100644 nitrogen/generated/shared/c++/CustomVideoMetadata.hpp create mode 100644 nitrogen/generated/shared/c++/HybridVideoPlayerEventEmitterSpec.cpp create mode 100644 nitrogen/generated/shared/c++/HybridVideoPlayerEventEmitterSpec.hpp create mode 100644 nitrogen/generated/shared/c++/HybridVideoPlayerFactorySpec.cpp create mode 100644 nitrogen/generated/shared/c++/HybridVideoPlayerFactorySpec.hpp create mode 100644 nitrogen/generated/shared/c++/HybridVideoPlayerSourceFactorySpec.cpp create mode 100644 nitrogen/generated/shared/c++/HybridVideoPlayerSourceFactorySpec.hpp create mode 100644 nitrogen/generated/shared/c++/HybridVideoPlayerSourceSpec.cpp create mode 100644 nitrogen/generated/shared/c++/HybridVideoPlayerSourceSpec.hpp create mode 100644 nitrogen/generated/shared/c++/HybridVideoPlayerSpec.cpp create mode 100644 nitrogen/generated/shared/c++/HybridVideoPlayerSpec.hpp create mode 100644 nitrogen/generated/shared/c++/HybridVideoViewViewManagerFactorySpec.cpp create mode 100644 nitrogen/generated/shared/c++/HybridVideoViewViewManagerFactorySpec.hpp create mode 100644 nitrogen/generated/shared/c++/HybridVideoViewViewManagerSpec.cpp create mode 100644 nitrogen/generated/shared/c++/HybridVideoViewViewManagerSpec.hpp create mode 100644 nitrogen/generated/shared/c++/IgnoreSilentSwitchMode.hpp create mode 100644 nitrogen/generated/shared/c++/ListenerSubscription.hpp create mode 100644 nitrogen/generated/shared/c++/LivePlaybackParams.hpp create mode 100644 nitrogen/generated/shared/c++/MixAudioMode.hpp create mode 100644 nitrogen/generated/shared/c++/NativeDrmParams.hpp create mode 100644 nitrogen/generated/shared/c++/NativeExternalSubtitle.hpp create mode 100644 nitrogen/generated/shared/c++/NativeVideoConfig.hpp create mode 100644 nitrogen/generated/shared/c++/OnGetLicensePayload.hpp create mode 100644 nitrogen/generated/shared/c++/ResizeMode.hpp create mode 100644 nitrogen/generated/shared/c++/Resolution.hpp create mode 100644 nitrogen/generated/shared/c++/SourceType.hpp create mode 100644 nitrogen/generated/shared/c++/SubtitleType.hpp create mode 100644 nitrogen/generated/shared/c++/SurfaceType.hpp create mode 100644 nitrogen/generated/shared/c++/TextTrack.hpp create mode 100644 nitrogen/generated/shared/c++/TimedMetadata.hpp create mode 100644 nitrogen/generated/shared/c++/TimedMetadataObject.hpp create mode 100644 nitrogen/generated/shared/c++/VideoInformation.hpp create mode 100644 nitrogen/generated/shared/c++/VideoOrientation.hpp create mode 100644 nitrogen/generated/shared/c++/VideoPlayerStatus.hpp create mode 100644 nitrogen/generated/shared/c++/onLoadData.hpp create mode 100644 nitrogen/generated/shared/c++/onLoadStartData.hpp create mode 100644 nitrogen/generated/shared/c++/onPlaybackStateChangeData.hpp create mode 100644 nitrogen/generated/shared/c++/onProgressData.hpp create mode 100644 nitrogen/generated/shared/c++/onVolumeChangeData.hpp create mode 100644 package.json create mode 100644 react-native.config.js create mode 100644 src/core/VideoPlayer.ts create mode 100644 src/core/VideoPlayer.web.ts create mode 100644 src/core/events/VideoPlayerEvents.native.ts create mode 100644 src/core/events/VideoPlayerEvents.ts create mode 100644 src/core/events/VideoPlayerEvents.web.ts create mode 100644 src/core/events/VideoPlayerEventsBase.ts create mode 100644 src/core/hooks/useEvent.ts create mode 100644 src/core/hooks/useManagedInstance.ts create mode 100644 src/core/hooks/useVideoPlayer.ts create mode 100644 src/core/types/BufferConfig.ts create mode 100644 src/core/types/DrmParams.ts create mode 100644 src/core/types/EventEmitter.ts create mode 100644 src/core/types/Events.ts create mode 100644 src/core/types/IgnoreSilentSwitchMode.ts create mode 100644 src/core/types/MixAudioMode.ts create mode 100644 src/core/types/ResizeMode.ts create mode 100644 src/core/types/TextTrack.ts create mode 100644 src/core/types/Utils.ts create mode 100644 src/core/types/VideoConfig.ts create mode 100644 src/core/types/VideoError.ts create mode 100644 src/core/types/VideoInformation.ts create mode 100644 src/core/types/VideoOrientation.ts create mode 100644 src/core/types/VideoPlayerBase.ts create mode 100644 src/core/types/VideoPlayerSourceBase.ts create mode 100644 src/core/types/VideoPlayerStatus.ts create mode 100644 src/core/utils/playerFactory.ts create mode 100644 src/core/utils/sourceFactory.ts create mode 100644 src/core/utils/sourceUtils.ts create mode 100644 src/core/video-view/NativeVideoView.tsx create mode 100644 src/core/video-view/VideoView.tsx create mode 100644 src/core/video-view/VideoView.web.tsx create mode 100644 src/core/video-view/VideoViewProps.ts create mode 100644 src/core/web/MediaSession.ts create mode 100644 src/core/web/WebEventEmitter.ts create mode 100644 src/expo-plugins/@types.ts create mode 100644 src/expo-plugins/getPackageInfo.ts create mode 100644 src/expo-plugins/withAndroidExtensions.ts create mode 100644 src/expo-plugins/withAndroidNotificationControls.ts create mode 100644 src/expo-plugins/withAndroidPictureInPicture.ts create mode 100644 src/expo-plugins/withBackgroundAudio.ts create mode 100644 src/expo-plugins/withReactNativeVideo.ts create mode 100644 src/expo-plugins/writeToPodfile.ts create mode 100644 src/index.tsx create mode 100644 src/spec/fabric/VideoViewNativeComponent.ts create mode 100644 src/spec/nitro/VideoPlayer.nitro.ts create mode 100644 src/spec/nitro/VideoPlayerEventEmitter.nitro.ts create mode 100644 src/spec/nitro/VideoPlayerSource.nitro.ts create mode 100644 src/spec/nitro/VideoViewViewManager.nitro.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 tsconfig.tsbuildinfo create mode 100644 tsconfig.web.json diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..56aa5e85 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + root: true, + extends: ["../../config/.eslintrc.js"], + parserOptions: { + tsconfigRootDir: __dirname, + project: true, + }, +}; diff --git a/.watchmanconfig b/.watchmanconfig new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/.watchmanconfig @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/ReactNativeVideo.podspec b/ReactNativeVideo.podspec new file mode 100644 index 00000000..26122a61 --- /dev/null +++ b/ReactNativeVideo.podspec @@ -0,0 +1,59 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) +folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32' + +fabric_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1' + +Pod::Spec.new do |s| + s.name = "ReactNativeVideo" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/KrzysztofMoch/react-native-video.git", :tag => "#{s.version}" } + + s.source_files = [ + "ios/*.{h,m,mm,swift}", + "ios/Core/**/*.{h,m,mm,swift}", # Core library files + "ios/Hybrids/**/*.{h,m,mm,swift}", # Nitro Hybrid files + "ios/View/**/*.{h,m,mm,swift}" # Video View files + ] + + if fabric_enabled + s.exclude_files = ["ios/view/paper/**/*.{h,m,mm,swift}"] + else + s.exclude_files = ["ios/view/fabric/**/*.{h,m,mm,swift}"] + end + + # Cxx to Swift bridging helpers + s.public_header_files = ["ios/Video-Bridging-Header.h"] + + s.pod_target_xcconfig = { + "GCC_PREPROCESSOR_DEFINITIONS" => "$(inherited) FOLLY_NO_CONFIG FOLLY_CFG_NO_COROUTINES FOLLY_MOBILE" + } + + # Try to manually add the dependencies + # because they are not automatically added by expo + # when USE_FRAMEWORKS is true + if ENV["USE_FRAMEWORKS"] + s.dependency "React-Core" + + puts "[ReactNativeVideo] Detected USE_FRAMEWORKS, adding required dependencies..." + + add_dependency(s, "React-jsinspector", :framework_name => "jsinspector_modern") + add_dependency(s, "React-rendererconsistency", :framework_name => "React_rendererconsistency") + + # @KrzysztofMoch Note: We need to add this as well for newer versions of React Native, but it's not available in older versions + # add_dependency(s, "React-jsinspectortracing", :framework_name => 'jsinspector_moderntracing') + end + + # Add all files generated by Nitrogen + load 'nitrogen/generated/ios/ReactNativeVideo+autolinking.rb' + add_nitrogen_files(s) + + install_modules_dependencies(s) +end diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt new file mode 100644 index 00000000..19a835a9 --- /dev/null +++ b/android/CMakeLists.txt @@ -0,0 +1,29 @@ + +project(ReactNativeVideo) +cmake_minimum_required(VERSION 3.9.0) + +set (PACKAGE_NAME ReactNativeVideo) +set (CMAKE_VERBOSE_MAKEFILE ON) +set (CMAKE_CXX_STANDARD 20) + +# Define C++ library and add all sources +add_library(${PACKAGE_NAME} SHARED + src/main/cpp/cpp-adapter.cpp +) + +# Add Nitrogen specs :) +include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/ReactNativeVideo+autolinking.cmake) + +# Set up local includes +include_directories( + "src/main/cpp" +) + +find_library(LOG_LIB log) + +# Link all libraries together +target_link_libraries( + ${PACKAGE_NAME} + ${LOG_LIB} + android # <-- Android core +) \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 00000000..ab032a12 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,250 @@ +buildscript { + // Buildscript is evaluated before everything else so we can't use getExtOrDefault + def kotlin_version = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : project.properties["RNVideo_kotlinVersion"] + def minKotlin_version = project.properties["RNVideo_minKotlinVersion"] + + def androidx_version = rootProject.ext.has('androidxActivityVersion') ? rootProject.ext.get('androidxActivityVersion') : project.properties['RNVideo_androidxActivityVersion'] + def minAndroidx_version = project.properties["RNVideo_androidxActivityVersion"] + + def isVersionAtLeast = { version, minVersion -> + def (major, minor, patch) = version.tokenize('.') + def (minMajor, minMinor, minPatch) = minVersion.tokenize('.') + + // major version is greater + if (major.toInteger() > minMajor.toInteger()) return true + + // major version is equal, minor version is greater + if (major.toInteger() == minMajor.toInteger() && minor.toInteger() > minMinor.toInteger()) return true + + // major version is equal, minor version is equal, patch version is greater + if (major.toInteger() == minMajor.toInteger() && minor.toInteger() == minMinor.toInteger() && patch.toInteger() >= minPatch.toInteger()) return true + + return false + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:7.2.1" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } + + ext { + if (!isVersionAtLeast(kotlin_version, minKotlin_version)) { + throw new GradleException("Kotlin version must be at least $minKotlin_version (current: $kotlin_version)") + } + + if (!isVersionAtLeast(androidx_version, minAndroidx_version)) { + throw new GradleException("AndroidX version must be at least $minAndroidx_version (current: $androidx_version)") + } + } +} + +def reactNativeArchitectures() { + def value = rootProject.getProperties().get("reactNativeArchitectures") + return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"] +} + +def isNewArchitectureEnabled() { + return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true" +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" +apply from: '../nitrogen/generated/android/ReactNativeVideo+autolinking.gradle' +apply from: './fix-prefab.gradle' + +if (isNewArchitectureEnabled()) { + apply plugin: "com.facebook.react" +} + +def getExtOrDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["RNVideo_" + name] +} + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["RNVideo_" + name]).toInteger() +} + +def supportsNamespace() { + def parsed = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.') + def major = parsed[0].toInteger() + def minor = parsed[1].toInteger() + + // Namespace support was added in 7.3.0 + return (major == 7 && minor >= 3) || major >= 8 +} + +def ExoplayerDependenciesList = [ + "useExoplayerDash", + "useExoplayerHls", +] + +def ExoplayerDependencies = ExoplayerDependenciesList.collectEntries { property -> + [(property): getExtOrDefault(property)?.toBoolean() ?: false] +} + +// Print Configuration +println "[ReactNativeVideo] Exoplayer Dependencies Configuration:" +ExoplayerDependenciesList.each { propertyName -> + def propertyValue = ExoplayerDependencies[propertyName] + println "$propertyName: $propertyValue" +} + +android { + if (supportsNamespace()) { + namespace "com.twg.video" + + sourceSets { + main { + manifest.srcFile "src/main/AndroidManifestNew.xml" + } + } + } + + ndkVersion getExtOrDefault("ndkVersion") + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + + buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() + buildConfigField "boolean", "USE_EXOPLAYER_DASH", ExoplayerDependencies.useExoplayerDash.toString() + buildConfigField "boolean", "USE_EXOPLAYER_HLS", ExoplayerDependencies.useExoplayerHls.toString() + + externalNativeBuild { + cmake { + cppFlags "-O2 -frtti -fexceptions -Wall -fstack-protector-all" + arguments "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" + abiFilters (*reactNativeArchitectures()) + } + } + } + + externalNativeBuild { + cmake { + path "CMakeLists.txt" + } + } + + packagingOptions { + excludes = [ + "META-INF", + "META-INF/**", + "**/libc++_shared.so", + "**/libfbjni.so", + "**/libjsi.so", + "**/libfolly_json.so", + "**/libfolly_runtime.so", + "**/libglog.so", + "**/libhermes.so", + "**/libhermes-executor-debug.so", + "**/libhermes_executor.so", + "**/libreactnativejni.so", + "**/libturbomodulejsijni.so", + "**/libreact_nativemodule_core.so", + "**/libjscexecutor.so", + "**/libreactnative.so" + ] + } + + buildFeatures { + buildConfig true + prefab true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + if (!isNewArchitectureEnabled()) { + java.srcDirs += ["src/paper/java"] + } + + if (!ExoplayerDependencies.useExoplayerDash) { + java.srcDirs += ["src/stubs/dash"] + } + + if (!ExoplayerDependencies.useExoplayerHls) { + java.srcDirs += ["src/stubs/hls"] + } + } + } +} + +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") +def media3_version = getExtOrDefault("media3Version") +def androidxCore_version = getExtOrDefault("androidxCoreVersion") +def androidxActivity_version = getExtOrDefault("androidxActivityVersion") + +dependencies { + // For < 0.71, this will be from the local maven repo + // For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin + //noinspection GradleDynamicVersion + implementation "com.facebook.react:react-native:+" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + // Add a dependency on NitroModules + implementation project(":react-native-nitro-modules") + + implementation "androidx.core:core-ktx:$androidxCore_version" + implementation "androidx.activity:activity-ktx:$androidxActivity_version" + + // For media playback using ExoPlayer + implementation "androidx.media3:media3-exoplayer:$media3_version" + + // Common functionality used across multiple media libraries + implementation "androidx.media3:media3-common:$media3_version" + + // For building media playback UIs + implementation "androidx.media3:media3-ui:$media3_version" + + // Common functionality for loading data + implementation "androidx.media3:media3-datasource:$media3_version" + + // For loading data using the OkHttp network stack + implementation "androidx.media3:media3-datasource-okhttp:$media3_version" + + // For Media Session + implementation "androidx.media3:media3-session:$media3_version" + + if (ExoplayerDependencies.useExoplayerDash) { + implementation "androidx.media3:media3-exoplayer-dash:$media3_version" + } + + if (ExoplayerDependencies.useExoplayerHls) { + implementation "androidx.media3:media3-exoplayer-hls:$media3_version" + } +} + +if (isNewArchitectureEnabled()) { + react { + jsRootDir = file("../src/spec/fabric") + libraryName = "RNCVideoView" + codegenJavaPackageName = "com.twg.video" + } +} + diff --git a/android/fix-prefab.gradle b/android/fix-prefab.gradle new file mode 100644 index 00000000..dfa5480f --- /dev/null +++ b/android/fix-prefab.gradle @@ -0,0 +1,51 @@ +tasks.configureEach { task -> + // Make sure that we generate our prefab publication file only after having built the native library + // so that not a header publication file, but a full configuration publication will be generated, which + // will include the .so file + + def prefabConfigurePattern = ~/^prefab(.+)ConfigurePackage$/ + def matcher = task.name =~ prefabConfigurePattern + if (matcher.matches()) { + def variantName = matcher[0][1] + task.outputs.upToDateWhen { false } + task.dependsOn("externalNativeBuild${variantName}") + } +} + +afterEvaluate { + def abis = reactNativeArchitectures() + rootProject.allprojects.each { proj -> + if (proj === rootProject) return + + def dependsOnThisLib = proj.configurations.findAll { it.canBeResolved }.any { config -> + config.dependencies.any { dep -> + dep.group == project.group && dep.name == project.name + } + } + if (!dependsOnThisLib && proj != project) return + + if (!proj.plugins.hasPlugin('com.android.application') && !proj.plugins.hasPlugin('com.android.library')) { + return + } + + def variants = proj.android.hasProperty('applicationVariants') ? proj.android.applicationVariants : proj.android.libraryVariants + // Touch the prefab_config.json files to ensure that in ExternalNativeJsonGenerator.kt we will re-trigger the prefab CLI to + // generate a libnameConfig.cmake file that will contain our native library (.so). + // See this condition: https://cs.android.com/android-studio/platform/tools/base/+/mirror-goog-studio-main:build-system/gradle-core/src/main/java/com/android/build/gradle/tasks/ExternalNativeJsonGenerator.kt;l=207-219?q=createPrefabBuildSystemGlue + variants.all { variant -> + def variantName = variant.name + abis.each { abi -> + def searchDir = new File(proj.projectDir, ".cxx/${variantName}") + if (!searchDir.exists()) return + def matches = [] + searchDir.eachDir { randomDir -> + def prefabFile = new File(randomDir, "${abi}/prefab_config.json") + if (prefabFile.exists()) matches << prefabFile + } + matches.each { prefabConfig -> + prefabConfig.setLastModified(System.currentTimeMillis()) + } + } + } + } +} \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..ee17bacd --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,13 @@ +RNVideo_kotlinVersion=1.9.24 +RNVideo_minKotlinVersion=1.8.0 +RNVideo_minSdkVersion=24 +RNVideo_targetSdkVersion=35 +RNVideo_compileSdkVersion=35 +RNVideo_ndkversion=27.1.12297006 + +RNVideo_useExoplayerDash=true +RNVideo_useExoplayerHls=true + +RNVideo_media3Version=1.4.1 +RNVideo_androidxCoreVersion=1.13.1 +RNVideo_androidxActivityVersion=1.9.3 diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..f79cac1a --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/src/main/AndroidManifestNew.xml b/android/src/main/AndroidManifestNew.xml new file mode 100644 index 00000000..f79cac1a --- /dev/null +++ b/android/src/main/AndroidManifestNew.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/src/main/cpp/cpp-adapter.cpp b/android/src/main/cpp/cpp-adapter.cpp new file mode 100644 index 00000000..6a2102df --- /dev/null +++ b/android/src/main/cpp/cpp-adapter.cpp @@ -0,0 +1,6 @@ +#include +#include "ReactNativeVideoOnLoad.hpp" + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + return margelo::nitro::video::initialize(vm); +} \ No newline at end of file diff --git a/android/src/main/java/com/twg/video/core/AudioFocusManager.kt b/android/src/main/java/com/twg/video/core/AudioFocusManager.kt new file mode 100644 index 00000000..770d656f --- /dev/null +++ b/android/src/main/java/com/twg/video/core/AudioFocusManager.kt @@ -0,0 +1,235 @@ +package com.twg.video.core + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.AudioFocusRequest +import android.os.Build +import androidx.annotation.OptIn +import androidx.annotation.RequiresApi +import androidx.media3.common.util.UnstableApi +import com.margelo.nitro.NitroModules +import com.margelo.nitro.video.HybridVideoPlayer +import com.margelo.nitro.video.MixAudioMode +import kotlin.getValue +import com.twg.video.core.utils.Threading + +@OptIn(UnstableApi::class) +class AudioFocusManager() { + private val players = mutableListOf() + private var currentMixAudioMode: MixAudioMode? = null + private var audioFocusRequest: AudioFocusRequest? = null + + val appContext by lazy { + NitroModules.applicationContext ?: throw UnknownError() + } + + private val audioManager by lazy { + appContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: throw UnknownError() + } + + private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> + Threading.runOnMainThread { + when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> { + unDuckActivePlayers() + } + AudioManager.AUDIOFOCUS_LOSS -> { + pauseActivePlayers() + currentMixAudioMode = null + audioFocusRequest = null + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + val mixAudioMode = determineRequiredMixMode() + if (mixAudioMode != MixAudioMode.MIXWITHOTHERS) { + pauseActivePlayers() + currentMixAudioMode = null + audioFocusRequest = null + } + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + val mixAudioMode = determineRequiredMixMode() + when (mixAudioMode) { + MixAudioMode.DONOTMIX -> pauseActivePlayers() + else -> duckActivePlayers() + } + } + } + } + } + + fun registerPlayer(player: HybridVideoPlayer) { + if (!players.contains(player)) { + players.add(player) + } + } + + fun unregisterPlayer(player: HybridVideoPlayer) { + players.remove(player) + if (players.isEmpty()) { + abandonAudioFocus() + } else { + requestAudioFocusUpdate() + } + } + + fun requestAudioFocusUpdate() { + Threading.runOnMainThread { + val requiredMixMode = determineRequiredMixMode() + + if (requiredMixMode == null) { + abandonAudioFocus() + return@runOnMainThread + } + + if (currentMixAudioMode != requiredMixMode) { + requestAudioFocus(requiredMixMode) + } + } + } + + private fun determineRequiredMixMode(): MixAudioMode? { + val activePlayers = players.filter { player -> + player.player.isPlaying && player.player.volume != 0f + } + + if (activePlayers.isEmpty()) { + return null + } + + val anyPlayerNeedsMixWithOthers = activePlayers.any { player -> + player.mixAudioMode == MixAudioMode.MIXWITHOTHERS + } + + if (anyPlayerNeedsMixWithOthers) { + abandonAudioFocus() + return MixAudioMode.MIXWITHOTHERS + } + + val anyPlayerNeedsExclusiveFocus = activePlayers.any { player -> + player.mixAudioMode == MixAudioMode.DONOTMIX + } + + val anyPlayerNeedsDucking = activePlayers.any { player -> + player.mixAudioMode == MixAudioMode.DUCKOTHERS + } + + return when { + anyPlayerNeedsExclusiveFocus -> MixAudioMode.DONOTMIX + anyPlayerNeedsDucking -> MixAudioMode.DUCKOTHERS + else -> MixAudioMode.AUTO + } + } + + private fun requestAudioFocus(mixMode: MixAudioMode) { + val focusType = when (mixMode) { + MixAudioMode.DONOTMIX -> AudioManager.AUDIOFOCUS_GAIN + MixAudioMode.DUCKOTHERS -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK + MixAudioMode.AUTO -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK + MixAudioMode.MIXWITHOTHERS -> return // No focus needed for mix with others + } + + val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + requestAudioFocusNew(focusType) + } else { + requestAudioFocusLegacy(focusType) + } + + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + currentMixAudioMode = mixMode + } else { + currentMixAudioMode = null + // Pause players since audio focus couldn't be obtained + pauseActivePlayers() + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun requestAudioFocusNew(focusType: Int): Int { + + audioFocusRequest = AudioFocusRequest.Builder(focusType) + .setAudioAttributes( + AudioAttributes.Builder().run { + setUsage(AudioAttributes.USAGE_MEDIA) + setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) + build() + } + ) + .setOnAudioFocusChangeListener(audioFocusChangeListener) + .build() + + return audioManager.requestAudioFocus(audioFocusRequest!!) + } + + @Suppress("DEPRECATION") + private fun requestAudioFocusLegacy(focusType: Int): Int { + return audioManager.requestAudioFocus( + audioFocusChangeListener, + AudioManager.STREAM_MUSIC, + focusType + ) + } + + private fun abandonAudioFocus() { + if (currentMixAudioMode != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + abandonAudioFocusNew() + } else { + abandonAudioFocusLegacy() + } + currentMixAudioMode = null + audioFocusRequest = null + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun abandonAudioFocusNew() { + audioFocusRequest?.let { request -> + audioManager.abandonAudioFocusRequest(request) + } + } + + @Suppress("DEPRECATION") + private fun abandonAudioFocusLegacy() { + audioManager.abandonAudioFocus(audioFocusChangeListener) + } + + private fun pauseActivePlayers() { + Threading.runOnMainThread { + players.forEach { player -> + player.player.let { mediaPlayer -> + if (mediaPlayer.volume != 0f && mediaPlayer.isPlaying) { + mediaPlayer.pause() + } + } + } + } + } + + private fun duckActivePlayers() { + Threading.runOnMainThread { + players.forEach { player -> + player.player.let { mediaPlayer -> + // We need to duck the volume to 50%. After the audio focus is regained, + // we will restore the volume to the user's volume. + mediaPlayer.volume = mediaPlayer.volume * 0.5f + } + } + } + } + + private fun unDuckActivePlayers() { + Threading.runOnMainThread { + // Resume players that were paused due to audio focus loss + players.forEach { player -> + player.player.let { mediaPlayer -> + // Restore full volume if it was ducked + if (mediaPlayer.volume != 0f && mediaPlayer.volume.toDouble() != player.userVolume) { + mediaPlayer.volume = player.userVolume.toFloat() + } + } + } + } + } +} + diff --git a/android/src/main/java/com/twg/video/core/VideoError.kt b/android/src/main/java/com/twg/video/core/VideoError.kt new file mode 100644 index 00000000..5968b162 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/VideoError.kt @@ -0,0 +1,105 @@ +package com.twg.video.core + +// Base class for all video errors +abstract class VideoError( + open val code: String, + message: String +) : Error("{%@$code::$message@%}") + +// Library related errors +sealed class LibraryError(code: String, message: String) : VideoError(code, message) { + object Deallocated : LibraryError( + "library/deallocated", + "Object has been deallocated" + ) + + object ApplicationContextNotFound : LibraryError( + "library/application-context-not-found", + "Application context not found" + ) + + class MethodNotSupported(val methodName: String) : LibraryError( + "library/method-not-supported", + "Method $methodName() is not supported on Android" + ) + + object DRMPluginNotFound : LibraryError( + "library/drm-plugin-not-found", + "No DRM plugin have been found, please add one to the project", + ) +} + +// Player related errors +sealed class PlayerError(code: String, message: String) : VideoError(code, message) { + object NotInitialized : PlayerError( + "player/not-initialized", + "Player has not been initialized (Or has been set to null)" + ) + + object AssetNotInitialized : PlayerError( + "player/asset-not-initialized", + "Asset has not been initialized (Or has been set to null)" + ) + + object InvalidSource : PlayerError( + "player/invalid-source", + "Invalid source passed to player" + ) + + object Cancelled : PlayerError( + "player/cancelled", + "Operation was cancelled" + ) +} + +// Source related errors +sealed class SourceError(code: String, message: String) : VideoError(code, message) { + class InvalidUri(val uri: String) : SourceError( + "source/invalid-uri", + "Invalid source file uri: $uri" + ) + + class MissingReadFilePermission(val uri: String) : SourceError( + "source/missing-read-file-permission", + "Missing read file permission for source file at $uri" + ) + + class FileDoesNotExist(val uri: String) : SourceError( + "source/file-does-not-exist", + "File does not exist at URI: $uri" + ) + + object FailedToInitializeAsset : SourceError( + "source/failed-to-initialize-asset", + "Failed to initialize asset" + ) + + class UnsupportedContentType(val uri: String) : SourceError( + "source/unsupported-content-type", + "type of content (${uri}) is not supported" + ) + + object Cancelled : SourceError( + "source/cancelled", + "Operation was cancelled" + ) +} + +// View related errors +sealed class VideoViewError(code: String, message: String) : VideoError(code, message) { + class ViewNotFound(val viewId: Int) : VideoViewError( + "view/not-found", + "View with viewId $viewId not found" + ) + object ViewIsDeallocated : VideoViewError( + "view/deallocated", + "Attempt to access a view, but it has been deallocated (or not initialized)" + ) + object PictureInPictureNotSupported : VideoViewError( + "view/picture-in-picture-not-supported", + "Picture in picture is not supported on this device" + ) +} + +// Unknown error +class UnknownError : VideoError("unknown/unknown", "Unknown error") diff --git a/android/src/main/java/com/twg/video/core/VideoManager.kt b/android/src/main/java/com/twg/video/core/VideoManager.kt new file mode 100644 index 00000000..c8bbca65 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/VideoManager.kt @@ -0,0 +1,292 @@ +package com.twg.video.core + +import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import com.facebook.react.bridge.LifecycleEventListener +import com.margelo.nitro.NitroModules +import com.margelo.nitro.video.HybridVideoPlayer +import com.margelo.nitro.video.MixAudioMode +import com.twg.video.core.plugins.PluginsRegistry +import com.twg.video.view.VideoView +import java.lang.ref.WeakReference + +@OptIn(UnstableApi::class) +object VideoManager : LifecycleEventListener { + private const val TAG = "VideoManager" + + // nitroId -> weak VideoView + private val views = mutableMapOf>() + // player -> list of nitroIds of views that are using this player + private val players = mutableMapOf>() + + // Keep track of players that were paused due to PiP so that they can be resumed later + private val playersPausedForPip = mutableSetOf() + + private var currentPipVideoView: WeakReference? = null + + var audioFocusManager = AudioFocusManager() + + private var lastPlayedNitroId: Int? = null + + init { + NitroModules.applicationContext?.apply { + addLifecycleEventListener(this@VideoManager) + } + } + + fun requestPictureInPicture(videoView: VideoView): Boolean { + Log.d(TAG, "PiP requested for video nitroId: ${videoView.nitroId}") + + if (videoView.isInPictureInPicture) { + Log.d(TAG, "Video nitroId: ${videoView.nitroId} is already in PiP") + return true + } + + // Exit PiP from current video if there is one + currentPipVideoView?.get()?.let { currentPipVideo -> + if (currentPipVideo != videoView && currentPipVideo.isInPictureInPicture) { + Log.d(TAG, "Forcing exit PiP for video nitroId: ${currentPipVideo.nitroId} to make room for nitroId: ${videoView.nitroId}") + currentPipVideo.forceExitPictureInPicture() + } + } + + // Ensure the player that belongs to this view is attached back to this view + videoView.hybridPlayer?.movePlayerToVideoView(videoView) + + // Pause every other player that might be playing in the background so that only the PiP video plays + pauseOtherPlayers(videoView) + + // Set this video as the designated PiP video BEFORE entering PiP mode + // This ensures the PictureInPictureHelperFragment callbacks know which video should respond + currentPipVideoView = WeakReference(videoView) + Log.d(TAG, "Designated video nitroId: ${videoView.nitroId} as the PiP video") + + val success = videoView.internalEnterPictureInPicture() + Log.d(TAG, "PiP enter result for video nitroId: ${videoView.nitroId} = $success") + + if (!success) { + // If we failed to enter PiP, resume any players we just paused + resumePlayersPausedForPip() + currentPipVideoView = null + Log.w(TAG, "Failed to enter PiP, clearing designated PiP video") + } + + return success + } + + fun notifyPictureInPictureExited(videoView: VideoView) { + Log.d(TAG, "PiP exit notification for video nitroId: ${videoView.nitroId}") + currentPipVideoView?.get()?.let { currentPipVideo -> + if (currentPipVideo == videoView) { + Log.d(TAG, "Clearing PiP reference for video nitroId: ${videoView.nitroId}") + currentPipVideoView = null + // Resume any players that were paused when PiP was entered + resumePlayersPausedForPip() + } + } + } + + fun getCurrentPictureInPictureVideo(): VideoView? { + return currentPipVideoView?.get() + } + + fun setCurrentPictureInPictureVideo(videoView: VideoView) { + Log.d(TAG, "Setting current PiP video to nitroId: ${videoView.nitroId}") + currentPipVideoView = WeakReference(videoView) + } + + fun isAnyVideoInPictureInPicture(): Boolean { + return currentPipVideoView?.get()?.isInPictureInPicture == true + } + + fun forceExitAllPictureInPicture() { + currentPipVideoView?.get()?.let { currentPipVideo -> + if (currentPipVideo.isInPictureInPicture) { + currentPipVideo.forceExitPictureInPicture() + } + } + currentPipVideoView = null + } + + fun maybePassPlayerToView(player: HybridVideoPlayer) { + val views = players[player]?.mapNotNull { getVideoViewWeakReferenceByNitroId(it)?.get() } ?: return + val latestView = views.lastOrNull() ?: return + + player.movePlayerToVideoView(latestView) + } + + fun registerView(view: VideoView) { + views[view.nitroId] = WeakReference(view) + PluginsRegistry.shared.notifyVideoViewCreated(WeakReference(view)) + } + + fun unregisterView(view: VideoView) { + view.hybridPlayer?.let { + removeViewFromPlayer(view, it) + } + + // Clean up PiP reference if this view was in PiP + currentPipVideoView?.get()?.let { currentPipVideo -> + if (currentPipVideo == view) { + currentPipVideoView = null + } + } + + views.remove(view.nitroId) + PluginsRegistry.shared.notifyVideoViewDestroyed(WeakReference(view)) + } + + fun addViewToPlayer(view: VideoView, player: HybridVideoPlayer) { + // Add player to list if it doesn't exist (should not happen) + if(!players.containsKey(player)) players[player] = mutableListOf() + + // Check if view is already added to player + if(players[player]?.contains(view.nitroId) == true) return + + // Add view to player + players[player]?.add(view.nitroId) + } + + fun removeViewFromPlayer(view: VideoView, player: HybridVideoPlayer) { + players[player]?.remove(view.nitroId) + + // If this was the last view using this player, clean up + if (players[player]?.isEmpty() == true) { + players.remove(player) + } else { + // If there are other views using this player, move to the latest one + maybePassPlayerToView(player) + } + } + + fun registerPlayer(player: HybridVideoPlayer) { + if (!players.containsKey(player)) { + players[player] = mutableListOf() + } + + audioFocusManager.registerPlayer(player) + PluginsRegistry.shared.notifyPlayerCreated(WeakReference(player)) + } + + fun unregisterPlayer(player: HybridVideoPlayer) { + audioFocusManager.unregisterPlayer(player) + PluginsRegistry.shared.notifyPlayerDestroyed(WeakReference(player)) + + // Remove player from any views that were using it + players[player]?.forEach { nitroId -> + views[nitroId]?.get()?.let { view -> + view.hybridPlayer = null + } + } + + players.remove(player) + } + + fun getPlayerByNitroId(nitroId: Int): HybridVideoPlayer? { + return players.keys.find { player -> + players[player]?.contains(nitroId) == true + } + } + + fun updateVideoViewNitroId(oldNitroId: Int, newNitroId: Int, view: VideoView) { + // Remove old mapping + if (oldNitroId != -1) { + views.remove(oldNitroId) + + // Update player mappings + players.keys.forEach { player -> + players[player]?.let { nitroIds -> + if (nitroIds.remove(oldNitroId)) { + nitroIds.add(newNitroId) + } + } + } + } + + // Add new mapping + views[newNitroId] = WeakReference(view) + } + + fun getVideoViewWeakReferenceByNitroId(nitroId: Int): WeakReference? { + return views[nitroId] + } + + // ------------ Lifecycle Handler ------------ + private fun onAppEnterForeground() { + players.keys.forEach { player -> + if (player.wasAutoPaused) { + player.play() + } + } + } + + private fun onAppEnterBackground() { + players.keys.forEach { player -> + if (!player.playInBackground && player.isPlaying) { + player.wasAutoPaused = player.isPlaying + player.pause() + } + } + } + + override fun onHostResume() { + onAppEnterForeground() + } + + override fun onHostPause() { + onAppEnterBackground() + } + + override fun onHostDestroy() { + forceExitAllPictureInPicture() + } + + fun pauseOtherPlayers(pipVideoView: VideoView) { + val pipPlayer = pipVideoView.hybridPlayer + playersPausedForPip.clear() + + players.keys.forEach { player -> + // Skip the player that is used for the PiP view + if (player == pipPlayer) return@forEach + + // Pause only if it is currently playing + if (player.isPlaying && player.mixAudioMode != MixAudioMode.MIXWITHOTHERS) { + player.pause() + playersPausedForPip.add(player) + Log.v(TAG, "Paused player for PiP (nitroIds: ${players[player]})") + } + } + } + + private fun resumePlayersPausedForPip() { + playersPausedForPip.forEach { player -> + // Ensure the player is attached to the latest visible VideoView before resuming + maybePassPlayerToView(player) + + if (!player.isPlaying) { + player.play() + Log.v(TAG, "Resumed player after PiP exit (nitroIds: ${players[player]})") + } + } + playersPausedForPip.clear() + } + + fun getAnyPlayingVideoView(): VideoView? { + return views.values.firstOrNull { ref -> + ref.get()?.hybridPlayer?.isPlaying == true + }?.get() + } + + fun setLastPlayedPlayer(player: HybridVideoPlayer) { + // Resolve to the latest view using this player (usually the last one in the list) + val nitroIds = players[player] ?: return + if (nitroIds.isNotEmpty()) { + lastPlayedNitroId = nitroIds.last() + } + } + + fun getLastPlayedVideoView(): VideoView? { + return lastPlayedNitroId?.let { views[it]?.get() } + } +} diff --git a/android/src/main/java/com/twg/video/core/extensions/ResizeMode+AspectRatioFrameLayout.kt b/android/src/main/java/com/twg/video/core/extensions/ResizeMode+AspectRatioFrameLayout.kt new file mode 100644 index 00000000..7fedeb5c --- /dev/null +++ b/android/src/main/java/com/twg/video/core/extensions/ResizeMode+AspectRatioFrameLayout.kt @@ -0,0 +1,20 @@ +package com.twg.video.core.extensions + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.AspectRatioFrameLayout +import com.margelo.nitro.video.ResizeMode + +@OptIn(UnstableApi::class) +/** + * Converts a [ResizeMode] to a [AspectRatioFrameLayout] resize mode. + * @return The corresponding [AspectRatioFrameLayout] resize mode. + */ +fun ResizeMode.toAspectRatioFrameLayout(): Int { + return when (this) { + ResizeMode.CONTAIN -> AspectRatioFrameLayout.RESIZE_MODE_FIT + ResizeMode.COVER -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + ResizeMode.STRETCH -> AspectRatioFrameLayout.RESIZE_MODE_FILL + ResizeMode.NONE -> AspectRatioFrameLayout.RESIZE_MODE_FIT + } +} diff --git a/android/src/main/java/com/twg/video/core/extensions/SubtitleType+toString.kt b/android/src/main/java/com/twg/video/core/extensions/SubtitleType+toString.kt new file mode 100644 index 00000000..abc52ce0 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/extensions/SubtitleType+toString.kt @@ -0,0 +1,14 @@ +package com.twg.video.core.extensions + +import com.margelo.nitro.video.SubtitleType + +fun SubtitleType.toStringExtension(): String { + return when { + this == SubtitleType.AUTO -> "auto" + this == SubtitleType.VTT -> "vtt" + this == SubtitleType.SRT -> "srt" + this == SubtitleType.SSA -> "ssa" + this == SubtitleType.ASS -> "ass" + else -> throw IllegalArgumentException("Unknown SubtitleType: $this") + } +} diff --git a/android/src/main/java/com/twg/video/core/extensions/VideoPlaybackService+ServiceManagment.kt b/android/src/main/java/com/twg/video/core/extensions/VideoPlaybackService+ServiceManagment.kt new file mode 100644 index 00000000..4abe4a93 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/extensions/VideoPlaybackService+ServiceManagment.kt @@ -0,0 +1,59 @@ +package com.twg.video.core.extensions + +import android.content.Context +import android.content.Context.BIND_AUTO_CREATE +import android.content.Context.BIND_INCLUDE_CAPABILITIES +import android.content.Intent +import android.os.Build +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import com.margelo.nitro.NitroModules +import com.margelo.nitro.video.HybridVideoPlayer +import com.twg.video.core.services.playback.VideoPlaybackService +import com.twg.video.core.services.playback.VideoPlaybackServiceConnection + +fun VideoPlaybackService.Companion.startService( + context: Context, + serviceConnection: VideoPlaybackServiceConnection +) { + val reactContext = NitroModules.applicationContext ?: return + + val intent = Intent(context, VideoPlaybackService::class.java) + intent.action = VIDEO_PLAYBACK_SERVICE_INTERFACE + + // Use startForegroundService on O+ so the service has the opportunity to call + // startForeground(...) quickly and avoid ForegroundServiceDidNotStartInTimeException. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + reactContext.startForegroundService(intent) + } catch (_: Exception) { + // Fall back to startService if anything goes wrong + try { reactContext.startService(intent) } catch (_: Exception) {} + } + } else { + reactContext.startService(intent) + } + + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + BIND_AUTO_CREATE or BIND_INCLUDE_CAPABILITIES + } else { + BIND_AUTO_CREATE + } + + context.bindService(intent, serviceConnection, flags) +} + +@OptIn(UnstableApi::class) +fun VideoPlaybackService.Companion.stopService( + player: HybridVideoPlayer, + serviceConnection: VideoPlaybackServiceConnection +) { + try { + // Unregister the player first; this might stop the service if no players remain + serviceConnection.unregisterPlayer(player) + // Ask service (if still connected) to stop when idle + try { serviceConnection.serviceBinder?.service?.stopIfNoPlayers() } catch (_: Exception) {} + // Then unbind + NitroModules.applicationContext?.currentActivity?.unbindService(serviceConnection) + } catch (_: Exception) {} +} diff --git a/android/src/main/java/com/twg/video/core/fragments/FullscreenVideoFragment.kt b/android/src/main/java/com/twg/video/core/fragments/FullscreenVideoFragment.kt new file mode 100644 index 00000000..32501561 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/fragments/FullscreenVideoFragment.kt @@ -0,0 +1,265 @@ +package com.twg.video.core.fragments + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Configuration +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowInsets +import android.view.WindowInsetsController +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.ImageButton +import androidx.activity.OnBackPressedCallback +import androidx.annotation.OptIn +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.media3.common.util.UnstableApi +import com.twg.video.core.utils.PictureInPictureUtils.createPictureInPictureParams +import com.twg.video.core.utils.SmallVideoPlayerOptimizer +import com.twg.video.view.VideoView +import java.util.UUID + +@OptIn(UnstableApi::class) +class FullscreenVideoFragment(private val videoView: VideoView) : Fragment() { + val id: String = UUID.randomUUID().toString() + + private var container: FrameLayout? = null + private var originalPlayerParent: ViewGroup? = null + private var originalPlayerLayoutParams: ViewGroup.LayoutParams? = null + private var rootContentViews: List = listOf() + + // Back press callback to handle back navigation + private val backPressCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + videoView.exitFullscreen() + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Create a fullscreen container + this.container = FrameLayout(requireContext()).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + setBackgroundColor(android.graphics.Color.BLACK) + keepScreenOn = true + } + return this.container + } + + override fun onResume() { + super.onResume() + + // System UI is re-enabled when user have exited app and go back + // We need to hide it again + hideSystemUI() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Register back press callback + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressCallback) + + enterFullscreenMode() + setupPlayerView() + hideSystemUI() + + // Update PiP params if supported + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + val params = createPictureInPictureParams(videoView) + requireActivity().setPictureInPictureParams(params) + } catch (_: Exception) {} + } + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode) + + if (isInPictureInPictureMode) { + videoView.playerView.useController = false + videoView.playerView.controllerAutoShow = false + } else { + videoView.playerView.useController = videoView.useController + videoView.playerView.controllerAutoShow = true + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + val isInPictureInPictureMode = requireActivity().isInPictureInPictureMode + + if (isInPictureInPictureMode) { + videoView.playerView.useController = false + videoView.playerView.controllerAutoShow = false + } else { + videoView.playerView.useController = videoView.useController + videoView.playerView.controllerAutoShow = true + } + } + + private fun enterFullscreenMode() { + // Store original parent and layout params + originalPlayerParent = videoView.playerView.parent as? ViewGroup + originalPlayerLayoutParams = videoView.playerView.layoutParams + + // Remove player from original parent + originalPlayerParent?.removeView(videoView.playerView) + + // Hide all root content views + val currentActivity = requireActivity() + val rootContent = currentActivity.window.decorView.findViewById(android.R.id.content) + rootContentViews = (0 until rootContent.childCount) + .map { rootContent.getChildAt(it) } + .filter { it.isVisible } + + rootContentViews.forEach { view -> + view.visibility = View.GONE + } + + // Add our fullscreen container to root + rootContent.addView(container, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + } + + private fun setupPlayerView() { + // Add PlayerView to our container + container?.addView(videoView.playerView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + + videoView.playerView.setBackgroundColor(android.graphics.Color.BLACK) + videoView.playerView.setShutterBackgroundColor(android.graphics.Color.BLACK) + + // We need show controls in fullscreen + videoView.playerView.useController = true + + setupFullscreenButton() + videoView.playerView.setShowSubtitleButton(true) + + // Apply optimizations based on video player size in fullscreen mode + SmallVideoPlayerOptimizer.applyOptimizations(videoView.playerView, requireContext(), isFullscreen = true) + } + + @SuppressLint("PrivateResource") + private fun setupFullscreenButton() { + videoView.playerView.setFullscreenButtonClickListener { _ -> + videoView.exitFullscreen() + } + + // Change icon to exit fullscreen + val button = videoView.playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen) + button?.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_exit) + } + + @Suppress("DEPRECATION") + private fun hideSystemUI() { + val currentActivity = requireActivity() + container?.let { container -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + container.fitsSystemWindows = false + container.windowInsetsController?.let { controller -> + controller.hide(WindowInsets.Type.systemBars()) + controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } else { + currentActivity.window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_FULLSCREEN + ) + } + } + } + + @Suppress("DEPRECATION") + private fun restoreSystemUI() { + val currentActivity = requireActivity() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + container?.windowInsetsController?.show(WindowInsets.Type.systemBars()) + } else { + currentActivity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE + } + } + + fun exitFullscreen() { + // Remove back press callback since we're exiting + backPressCallback.remove() + + restoreSystemUI() + + // Keep controls disabled if in PiP mode - media session creates its own controls for PiP + val isInPictureInPictureMode = requireActivity().isInPictureInPictureMode + if (isInPictureInPictureMode) { + videoView.playerView.useController = false + } else if (videoView.useController == false) { + videoView.playerView.useController = false + } + + // Ensure PlayerView keeps black background when returning to normal mode + videoView.playerView.setBackgroundColor(android.graphics.Color.BLACK) + videoView.playerView.setShutterBackgroundColor(android.graphics.Color.BLACK) + + // Remove PlayerView from our container + container?.removeView(videoView.playerView) + + // Remove our container from root + val currentActivity = requireActivity() + val rootContent = currentActivity.window.decorView.findViewById(android.R.id.content) + rootContent.removeView(container) + + // Restore root content views + rootContentViews.forEach { it.visibility = View.VISIBLE } + rootContentViews = listOf() + + // Safely restore PlayerView to original parent + // First, ensure PlayerView is removed from any current parent + val currentParent = videoView.playerView.parent as? ViewGroup + currentParent?.removeView(videoView.playerView) + + // Now add it back to the original parent if it's not already the parent + if (videoView.playerView.parent != originalPlayerParent) { + originalPlayerParent?.addView(videoView.playerView, originalPlayerLayoutParams) + } + + // Remove this fragment + parentFragmentManager.beginTransaction() + .remove(this) + .commitAllowingStateLoss() + + // Notify VideoView that we've exited fullscreen + videoView.isInFullscreen = false + } + + + + override fun onDestroy() { + super.onDestroy() + + // Ensure we clean up properly if fragment is destroyed + if (videoView.isInFullscreen) { + exitFullscreen() + } + } +} diff --git a/android/src/main/java/com/twg/video/core/fragments/PictureInPictureHelperFragment.kt b/android/src/main/java/com/twg/video/core/fragments/PictureInPictureHelperFragment.kt new file mode 100644 index 00000000..0d301d70 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/fragments/PictureInPictureHelperFragment.kt @@ -0,0 +1,85 @@ +package com.twg.video.core.fragments + +import android.content.res.Configuration +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.annotation.OptIn +import androidx.fragment.app.Fragment +import androidx.media3.common.util.UnstableApi +import com.twg.video.core.VideoManager +import com.twg.video.view.VideoView +import java.util.UUID + +@OptIn(UnstableApi::class) +class PictureInPictureHelperFragment(private val videoView: VideoView) : Fragment() { + val id: String = UUID.randomUUID().toString() + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode) + + if (isInPictureInPictureMode) { + var currentPipVideo = VideoManager.getCurrentPictureInPictureVideo() + + if (currentPipVideo == null) { + synchronized(VideoManager) { + currentPipVideo = VideoManager.getCurrentPictureInPictureVideo() + if (currentPipVideo == null) { + if (videoView.hybridPlayer?.isPlaying == true) { + val lastPlayed = VideoManager.getLastPlayedVideoView() + val shouldDesignate = lastPlayed == null || lastPlayed.nitroId == videoView.nitroId + + if (shouldDesignate) { + VideoManager.setCurrentPictureInPictureVideo(videoView) + videoView.hybridPlayer?.movePlayerToVideoView(videoView) + VideoManager.pauseOtherPlayers(videoView) + currentPipVideo = videoView + } + } else { + if (!VideoManager.isAnyVideoInPictureInPicture()) { + val lastPlayed = VideoManager.getLastPlayedVideoView() + val targetView = lastPlayed ?: videoView + + VideoManager.setCurrentPictureInPictureVideo(targetView) + targetView.hybridPlayer?.movePlayerToVideoView(targetView) + VideoManager.pauseOtherPlayers(targetView) + currentPipVideo = targetView + } + } + } + } + } + + if (currentPipVideo == videoView) { + // Disable controls immediately when entering PiP - media session creates its own controls for PiP + videoView.playerView.useController = false + videoView.playerView.controllerAutoShow = false + + // If we're currently in fullscreen, exit it first to prevent parent conflicts + if (videoView.isInFullscreen) { + try { + videoView.exitFullscreen() + // Post hideRootContentViews to main thread to ensure it runs after exitFullscreen completes + Handler(Looper.getMainLooper()).post { + videoView.hideRootContentViews() + videoView.isInPictureInPicture = true + } + } catch (e: Exception) { + Log.w("ReactNativeVideo", "Failed to exit fullscreen before entering PiP for nitroId: ${videoView.nitroId}", e) + videoView.hideRootContentViews() + videoView.isInPictureInPicture = true + } + } else { + // Now move the PlayerView to the root for PiP and hide content + videoView.hideRootContentViews() + videoView.isInPictureInPicture = true + } + } + } else { + if (videoView.isInPictureInPicture) { + videoView.exitPictureInPicture() + } + } + } +} diff --git a/android/src/main/java/com/twg/video/core/player/DRMManagerSpec.kt b/android/src/main/java/com/twg/video/core/player/DRMManagerSpec.kt new file mode 100644 index 00000000..d64e8174 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/player/DRMManagerSpec.kt @@ -0,0 +1,23 @@ +package com.twg.video.core.player + +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.exoplayer.drm.DrmSessionManager +import com.margelo.nitro.video.NativeDrmParams +import java.util.UUID + +@OptIn(UnstableApi::class) +interface DRMManagerSpec { + fun buildDrmSessionManager(drmParams: NativeDrmParams): DrmSessionManager { + val drmScheme = drmParams.type ?: "widevine" + val drmUuid = Util.getDrmUuid(drmScheme) + + return buildDrmSessionManager(drmParams, drmUuid) + } + + fun buildDrmSessionManager(drmParams: NativeDrmParams, drmUuid: UUID?, retryCount: Int = 0): DrmSessionManager + + fun getDRMConfiguration(drmParams: NativeDrmParams): MediaItem.DrmConfiguration +} \ No newline at end of file diff --git a/android/src/main/java/com/twg/video/core/player/DataSourceFactoryUtils.kt b/android/src/main/java/com/twg/video/core/player/DataSourceFactoryUtils.kt new file mode 100644 index 00000000..02b03aa6 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/player/DataSourceFactoryUtils.kt @@ -0,0 +1,51 @@ +package com.twg.video.core.player + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource +import com.facebook.react.bridge.ReactContext +import com.facebook.react.modules.network.CookieJarContainer +import com.facebook.react.modules.network.ForwardingCookieHandler +import com.facebook.react.modules.network.OkHttpClientProvider +import com.margelo.nitro.video.HybridVideoPlayerSourceSpec +import okhttp3.JavaNetCookieJar + +fun buildBaseDataSourceFactory(context: Context, source: HybridVideoPlayerSourceSpec): DefaultDataSource.Factory { + return if (source.uri.startsWith("http")) { + DefaultDataSource.Factory(context, buildHttpDataSourceFactory(context, source)) + } else { + DefaultDataSource.Factory(context) + } +} + +@OptIn(UnstableApi::class) +fun buildHttpDataSourceFactory(context: Context, source: HybridVideoPlayerSourceSpec): OkHttpDataSource.Factory { + val client = OkHttpClientProvider.getOkHttpClient() + + if (context is ReactContext) { + val handler = ForwardingCookieHandler(context) + (client.cookieJar as CookieJarContainer).setCookieJar(JavaNetCookieJar(handler)) + } + + val factory = OkHttpDataSource.Factory(client) + + val headers: Map? = source.config.headers + + if (headers != null) { + factory.setDefaultRequestProperties(headers) + } + + if (headers == null || !headers.containsKey("User-Agent")) { + factory.setUserAgent(getUserAgent(context)) + } + + return factory +} + +@OptIn(UnstableApi::class) +fun getUserAgent(context: Context): String { + return Util.getUserAgent(context, context.packageName) +} diff --git a/android/src/main/java/com/twg/video/core/player/MediaItemUtils.kt b/android/src/main/java/com/twg/video/core/player/MediaItemUtils.kt new file mode 100644 index 00000000..329ada25 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/player/MediaItemUtils.kt @@ -0,0 +1,141 @@ +package com.twg.video.core.player + +import android.util.Log +import android.webkit.MimeTypeMap +import androidx.annotation.OptIn +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.C +import androidx.media3.common.MediaMetadata +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import com.margelo.nitro.video.BufferConfig +import com.margelo.nitro.video.CustomVideoMetadata +import com.margelo.nitro.video.HybridVideoPlayerSource +import com.margelo.nitro.video.LivePlaybackParams +import com.margelo.nitro.video.NativeDrmParams +import com.margelo.nitro.video.NativeVideoConfig +import com.margelo.nitro.video.SubtitleType +import com.twg.video.core.LibraryError +import com.twg.video.core.SourceError +import com.twg.video.core.extensions.toStringExtension +import com.twg.video.core.plugins.PluginsRegistry + +private const val TAG = "MediaItemUtils" + +@OptIn(UnstableApi::class) +fun createMediaItemFromVideoConfig( + source: HybridVideoPlayerSource +): MediaItem { + val mediaItemBuilder = MediaItem.Builder() + + mediaItemBuilder.setUri(source.config.uri) + + source.config.drm?.let { drmParams -> + val drmManager = source.drmManager ?: throw LibraryError.DRMPluginNotFound + val drmConfiguration = drmManager.getDRMConfiguration(drmParams) + mediaItemBuilder.setDrmConfiguration(drmConfiguration) + } + + source.config.bufferConfig?.livePlayback?.let { livePlaybackParams -> + mediaItemBuilder.setLiveConfiguration(getLiveConfiguration(livePlaybackParams)) + } + + source.config.metadata?.let { metadata -> + mediaItemBuilder.setMediaMetadata(getCustomMetadata(metadata)) + } + + return PluginsRegistry.shared.overrideMediaItemBuilder( + source, + mediaItemBuilder + ).build() +} + +fun getSubtitlesConfiguration( + config: NativeVideoConfig, +): List { + val subtitlesConfiguration: MutableList = mutableListOf() + + if (config.externalSubtitles != null) { + for (subtitle in config.externalSubtitles) { + val ext = if (subtitle.type == SubtitleType.AUTO) { + MimeTypeMap.getFileExtensionFromUrl(subtitle.uri) + } else { + subtitle.type.toStringExtension() + } + + val mimeType = when (ext?.lowercase()) { + "srt" -> MimeTypes.APPLICATION_SUBRIP + "vtt" -> MimeTypes.TEXT_VTT + "ssa", "ass" -> MimeTypes.TEXT_SSA + else -> { + Log.e(TAG, "Unsupported subtitle extension '$ext' for URI: ${subtitle.uri}. Skipping this subtitle.") + continue + } + } + + try { + val subtitleConfig = MediaItem.SubtitleConfiguration.Builder(subtitle.uri.toUri()) + .setId("external-subtitle-${subtitle.uri}") + .setMimeType(mimeType) + .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) + .setRoleFlags(C.ROLE_FLAG_SUBTITLE) + .setLabel(subtitle.label) + .build() + subtitlesConfiguration.add(subtitleConfig) + } catch (e: Exception) { + Log.e(TAG, "Error creating SubtitleConfiguration for URI ${subtitle.uri}: ${e.message}", e) + } + } + } + return subtitlesConfiguration +} + +fun getLiveConfiguration( + livePlaybackParams: LivePlaybackParams +): MediaItem.LiveConfiguration { + val liveConfiguration = MediaItem.LiveConfiguration.Builder() + + livePlaybackParams.maxOffsetMs?.let { + if (it >= 0) { + liveConfiguration.setMaxOffsetMs(it.toLong()) + } + } + + livePlaybackParams.minOffsetMs?.let { + if (it >= 0) { + liveConfiguration.setMinOffsetMs(it.toLong()) + } + } + + livePlaybackParams.targetOffsetMs?.let { + if (it >= 0) { + liveConfiguration.setTargetOffsetMs(it.toLong()) + } + } + + livePlaybackParams.maxPlaybackSpeed?.let { + if (it >= 0) { + liveConfiguration.setMaxPlaybackSpeed(it.toFloat()) + } + } + + livePlaybackParams.minPlaybackSpeed?.let { + if (it >= 0) { + liveConfiguration.setMinPlaybackSpeed(it.toFloat()) + } + } + + return liveConfiguration.build() +} + +fun getCustomMetadata(metadata: CustomVideoMetadata): MediaMetadata { + return MediaMetadata.Builder() + .setDisplayTitle(metadata.title) + .setTitle(metadata.title) + .setSubtitle(metadata.subtitle) + .setDescription(metadata.description) + .setArtist(metadata.artist) + .setArtworkUri(metadata.imageUri?.toUri()) + .build() +} diff --git a/android/src/main/java/com/twg/video/core/player/MediaSourceUtils.kt b/android/src/main/java/com/twg/video/core/player/MediaSourceUtils.kt new file mode 100644 index 00000000..0d422099 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/player/MediaSourceUtils.kt @@ -0,0 +1,112 @@ +package com.twg.video.core.player + +import android.content.Context +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.util.Util +import androidx.media3.exoplayer.source.MediaSource +import com.margelo.nitro.video.HybridVideoPlayerSourceSpec +import androidx.core.net.toUri +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.dash.DashMediaSource +import androidx.media3.exoplayer.drm.DrmSessionManager +import androidx.media3.exoplayer.hls.HlsMediaSource +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.MergingMediaSource +import com.margelo.nitro.video.HybridVideoPlayerSource +import com.twg.video.core.LibraryError +import com.twg.video.core.SourceError +import com.twg.video.core.plugins.PluginsRegistry + +@OptIn(UnstableApi::class) +@Throws(SourceError::class) +fun buildMediaSource(context: Context, source: HybridVideoPlayerSource, mediaItem: MediaItem): MediaSource { + val uri = source.uri.toUri() + + // Explanation: + // 1. Remove query params from uri to avoid getting false extension + // 2. Get extension from uri + val type = Util.inferContentType(uri) + val dataSourceFactory = PluginsRegistry.shared.overrideMediaDataSourceFactory( + source, + buildBaseDataSourceFactory(context, source) + ) + + if (!source.config.externalSubtitles.isNullOrEmpty()) { + return buildExternalSubtitlesMediaSource(context, source) + } + + val mediaSourceFactory: MediaSource.Factory = when (type) { + C.CONTENT_TYPE_DASH -> { + DashMediaSource.Factory(dataSourceFactory) + } + C.CONTENT_TYPE_HLS -> { + HlsMediaSource.Factory(dataSourceFactory) + } + C.CONTENT_TYPE_OTHER -> { + DefaultMediaSourceFactory(context) + .setDataSourceFactory(dataSourceFactory) + } + else -> { + throw SourceError.UnsupportedContentType(source.uri) + } + } + + source.config.drm?.let { + val drmSessionManager = source.drmSessionManager ?: throw LibraryError.DRMPluginNotFound + mediaSourceFactory.setDrmSessionManagerProvider { drmSessionManager } + } + + return PluginsRegistry.shared.overrideMediaSourceFactory( + source, + mediaSourceFactory, + dataSourceFactory + ).createMediaSource(mediaItem) +} + +@OptIn(UnstableApi::class) +fun buildExternalSubtitlesMediaSource(context: Context, source: HybridVideoPlayerSource): MediaSource { + val dataSourceFactory = PluginsRegistry.shared.overrideMediaDataSourceFactory( + source, + buildBaseDataSourceFactory(context, source) + ) + + val mediaItemBuilderWithSubtitles = MediaItem.Builder() + .setUri(source.uri.toUri()) + .setSubtitleConfigurations(getSubtitlesConfiguration(source.config)) + + source.config.metadata?.let { metadata -> + mediaItemBuilderWithSubtitles.setMediaMetadata(getCustomMetadata(metadata)) + } + + val mediaItemBuilder = PluginsRegistry.shared.overrideMediaItemBuilder( + source, + mediaItemBuilderWithSubtitles + ) + + val mediaSourceFactory = DefaultMediaSourceFactory(context) + .setDataSourceFactory(dataSourceFactory) + + if (source.config.drm != null) { + if (source.drmManager == null) { + throw LibraryError.DRMPluginNotFound + } + + mediaSourceFactory.setDrmSessionManagerProvider { + source.drmManager as DrmSessionManager + } + + val drmConfiguration = source.drmManager!!.getDRMConfiguration(source.config.drm!!) + mediaItemBuilder.setDrmConfiguration(drmConfiguration) + } + + return PluginsRegistry.shared.overrideMediaSourceFactory( + source, + mediaSourceFactory, + dataSourceFactory + ).createMediaSource(mediaItemBuilder.build()) +} + + diff --git a/android/src/main/java/com/twg/video/core/player/OnAudioFocusChangedListener.kt b/android/src/main/java/com/twg/video/core/player/OnAudioFocusChangedListener.kt new file mode 100644 index 00000000..d681e88f --- /dev/null +++ b/android/src/main/java/com/twg/video/core/player/OnAudioFocusChangedListener.kt @@ -0,0 +1,25 @@ +package com.twg.video.core.player + +import android.media.AudioManager +import com.margelo.nitro.video.HybridVideoPlayerEventEmitter + +// TODO: We should make VideoFocusManager that will track focus globally for now lets just do simple listener +class OnAudioFocusChangedListener : AudioManager.OnAudioFocusChangeListener { + private var eventEmitter: HybridVideoPlayerEventEmitter? = null + + override fun onAudioFocusChange(focusChange: Int) { + when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> eventEmitter?.onAudioFocusChange(true) + AudioManager.AUDIOFOCUS_LOSS -> eventEmitter?.onAudioFocusChange(false) + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> eventEmitter?.onAudioFocusChange(false) + } + } + + fun setEventEmitter(eventEmitter: HybridVideoPlayerEventEmitter) { + this.eventEmitter = eventEmitter + } + + fun removeEventEmitter() { + this.eventEmitter = null + } +} diff --git a/android/src/main/java/com/twg/video/core/plugins/PluginsRegistry.kt b/android/src/main/java/com/twg/video/core/plugins/PluginsRegistry.kt new file mode 100644 index 00000000..eabdbcb6 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/plugins/PluginsRegistry.kt @@ -0,0 +1,223 @@ +package com.twg.video.core.plugins + +import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.source.MediaSource +import com.margelo.nitro.video.HybridVideoPlayer +import com.margelo.nitro.video.HybridVideoPlayerSource +import com.twg.video.BuildConfig +import com.twg.video.core.LibraryError +import com.twg.video.core.player.DRMManagerSpec +import com.twg.video.view.VideoView +import java.lang.ref.WeakReference + +// Keep these types for platform compatibility +// On iOS we cannot just export HybridVideoPlayer so we need to keep this typealias +typealias NativeVideoPlayer = HybridVideoPlayer +typealias NativeVideoPlayerSource = HybridVideoPlayerSource + +class PluginsRegistry { + // Plugin ID -> ReactNativeVideoPluginSpec + private val plugins: MutableMap = mutableMapOf() + + companion object { + val shared = PluginsRegistry() + private const val TAG = "ReactNativeVideoPluginsRegistry" + } + + // Public methods + fun register(plugin: ReactNativeVideoPluginSpec) { + if(hasPlugin(plugin)) { + plugins.replace(plugin.id, plugin) + + if (BuildConfig.DEBUG) { + Log.d(TAG, "Replaced plugin ${plugin.name} (ID: ${plugin.id})") + } + + return + } + + plugins.put(plugin.id, plugin) + + if (BuildConfig.DEBUG) { + Log.d(TAG, "Registered plugin ${plugin.name} (ID: ${plugin.id})") + } + } + + @Suppress("unused") + fun unregister(plugin: ReactNativeVideoPluginSpec) { + if (!hasPlugin(plugin)) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "Tried to unregister plugin ${plugin.name} (ID: ${plugin.id}), but it was not registered") + } + + return + } + + plugins.remove(plugin.id) + + if (BuildConfig.DEBUG) { + Log.d(TAG, "Unregistered plugin ${plugin.name} (ID: ${plugin.id})") + } + } + + // Notifications + @OptIn(UnstableApi::class) + internal fun notifyPlayerCreated(player: WeakReference) { + plugins.values.forEach { it.onPlayerCreated(player) } + } + + @OptIn(UnstableApi::class) + internal fun notifyPlayerDestroyed(player: WeakReference) { + plugins.values.forEach { it.onPlayerDestroyed(player) } + } + + @OptIn(UnstableApi::class) + internal fun notifyVideoViewCreated(view: WeakReference) { + plugins.values.forEach { it.onVideoViewCreated(view) } + } + + @OptIn(UnstableApi::class) + internal fun notifyVideoViewDestroyed(view: WeakReference) { + plugins.values.forEach { it.onVideoViewDestroyed(view) } + } + + // Internal methods + + /** + * Maybe override the source with the plugins. + * + * This method is used to override the source with the plugins. + * It is called when a source is created and is used to override the source with the plugins. + * + * It is not guaranteed that the source will be overridden with the plugins. + * If no plugin overrides the source, the original source is returned. + * + * @param source The source instance. + * @return The maybe overridden source instance. + */ + internal fun overrideSource(source: NativeVideoPlayerSource): NativeVideoPlayerSource { + var overriddenSource = source + + for (plugin in plugins.values) { + overriddenSource = plugin.overrideSource(overriddenSource) + } + + return overriddenSource + } + + /** + * Returns the DRM manager instance from the plugins. + * + * @throws LibraryError.DRMPluginNotFound If no DRM manager is found. + * @return Any + */ + internal fun getDRMManager(source: NativeVideoPlayerSource): DRMManagerSpec { + for (plugin in plugins.values) { + val manager = plugin.getDRMManager(source) + + if (manager != null) return manager + } + + throw LibraryError.DRMPluginNotFound + } + + /** + * Maybe override the media data source factory with the plugins. + * + * If no plugin overrides the media data source factory, the original factory is returned. + * + * @param source The source instance. + * @param mediaDataSourceFactory The media data source factory instance. + * @return The maybe overridden media data source factory instance. + */ + internal fun overrideMediaDataSourceFactory( + source: NativeVideoPlayerSource, + mediaDataSourceFactory: DataSource.Factory + ): DataSource.Factory { + for (plugin in plugins.values) { + val factory = plugin.getMediaDataSourceFactory(source, mediaDataSourceFactory) + + if (factory != null) return factory + } + + return mediaDataSourceFactory + } + + /** + * Maybe override the media source factory with the plugins. + * + * If no plugin overrides the media source factory, the original factory is returned. + * + * @param source The source instance. + * @param mediaSourceFactory The media source factory instance. + * @param mediaDataSourceFactory The media data source factory instance. + * @return The maybe overridden media source factory instance. + */ + internal fun overrideMediaSourceFactory( + source: NativeVideoPlayerSource, + mediaSourceFactory: MediaSource.Factory, + mediaDataSourceFactory: DataSource.Factory + ): MediaSource.Factory { + for (plugin in plugins.values) { + val factory = plugin.getMediaSourceFactory(source, mediaSourceFactory, mediaDataSourceFactory) + + if (factory != null) return factory + } + + return mediaSourceFactory + } + + /** + * Maybe override the media item builder with the plugins. + * + * If no plugin overrides the media item builder, the original builder is returned. + * + * @param source The source instance. + * @param mediaItemBuilder The media item builder instance. + * @return The maybe overridden media item builder instance. + */ + internal fun overrideMediaItemBuilder( + source: NativeVideoPlayerSource, + mediaItemBuilder: MediaItem.Builder + ): MediaItem.Builder { + for (plugin in plugins.values) { + val builder = plugin.getMediaItemBuilder(source, mediaItemBuilder) + + if (builder != null) return builder + } + + return mediaItemBuilder + } + + /** + * Maybe disable the cache with the plugins. + * + * If no plugin disables the cache, the original cache is not disabled. + * + * @param source The source instance. + * @return The maybe disabled cache. + */ + internal fun shouldDisableCache(source: NativeVideoPlayerSource): Boolean { + for (plugin in plugins.values) { + val shouldDisable = plugin.shouldDisableCache(source) + + if (shouldDisable) return true + } + + return false + } + + /** + * Checks if a plugin is registered. + * + * @param plugin The plugin instance. + * @return True if the plugin is registered, false otherwise. + */ + internal fun hasPlugin(plugin: ReactNativeVideoPluginSpec): Boolean { + return plugins.any { it.key == plugin.id } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/twg/video/core/plugins/ReactNativeVideoPlugin.kt b/android/src/main/java/com/twg/video/core/plugins/ReactNativeVideoPlugin.kt new file mode 100644 index 00000000..007b68fe --- /dev/null +++ b/android/src/main/java/com/twg/video/core/plugins/ReactNativeVideoPlugin.kt @@ -0,0 +1,173 @@ +package com.twg.video.core.plugins + +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.source.MediaSource +import com.twg.video.core.player.DRMManagerSpec +import com.twg.video.view.VideoView +import java.lang.ref.WeakReference + +interface ReactNativeVideoPluginSpec { + /** + * The ID of the plugin. + */ + val id: String + + /** + * The name of the plugin. + */ + val name: String + + /** + * Called when a player is created. + * + * @param player The weak reference to the player instance. + */ + @UnstableApi + fun onPlayerCreated(player: WeakReference) + + /** + * Called when a player is destroyed. + * + * @param player The weak reference to the player instance. + */ + @UnstableApi + fun onPlayerDestroyed(player: WeakReference) + + /** + * Called when a video view is created. + * + * @param view The weak reference to the video view instance. + */ + @UnstableApi + fun onVideoViewCreated(view: WeakReference) + + /** + * Called when a video view is destroyed. + * + * @param view The weak reference to the video view instance. + */ + @UnstableApi + fun onVideoViewDestroyed(view: WeakReference) + + /** + * Called when a source is being used to create mediaItem or MediaSource. + * You can use it to modify the source before it is used. + * + * @param source The source instance. + * @return The overridden source instance. + */ + fun overrideSource(source: NativeVideoPlayerSource): NativeVideoPlayerSource + + /** + * Called when a DRM manager is requested. + * + * @return The DRM manager instance. + */ + fun getDRMManager(source: NativeVideoPlayerSource): DRMManagerSpec? + + + /** + * Called when a media data source factory is requested. + * + * @param source The source instance. + * @param mediaDataSourceFactory The media data source factory. + * @return The media data source factory. If null is returned, the default factory will be used. + */ + fun getMediaDataSourceFactory( + source: NativeVideoPlayerSource, + mediaDataSourceFactory: DataSource.Factory + ): DataSource.Factory? + + /** + * Called when a media source factory is requested. + * + * @param source The source instance. + * @param mediaSourceFactory The media source factory. + * @param mediaDataSourceFactory The media data source factory. + * @return The media source factory. If null is returned, the default factory will be used. + */ + fun getMediaSourceFactory( + source: NativeVideoPlayerSource, + mediaSourceFactory: MediaSource.Factory, + mediaDataSourceFactory: DataSource.Factory + ): MediaSource.Factory? + + /** + * Called when a media item builder is requested. + * + * @param source The source instance. + * @param mediaItemBuilder The media item builder. + * @return The media item builder. If null is returned, the default builder will be used. + */ + fun getMediaItemBuilder( + source: NativeVideoPlayerSource, + mediaItemBuilder: MediaItem.Builder + ): MediaItem.Builder? + + /** + * Called when a cache should be disabled. + * + * @param source The source instance. + * @return True if cache should be disabled, false otherwise. + */ + fun shouldDisableCache(source: NativeVideoPlayerSource): Boolean +} + +@Suppress("Unused") +/** + * A helper base implementation of the ReactNativeVideoPluginSpec interface. + */ +open class ReactNativeVideoPlugin(override val name: String) : ReactNativeVideoPluginSpec { + override val id = "RNV_Plugin_${name}" + + init { + // Automatically register the plugin when it is created + PluginsRegistry.shared.register(this) + } + + @UnstableApi + override fun onPlayerCreated(player: WeakReference) { /* NOOP */} + + @UnstableApi + override fun onPlayerDestroyed(player: WeakReference) { /* NOOP */} + + @UnstableApi + override fun onVideoViewCreated(view: WeakReference) { /* NOOP */} + + @UnstableApi + override fun onVideoViewDestroyed(view: WeakReference) { /* NOOP */} + + override fun overrideSource(source: NativeVideoPlayerSource): NativeVideoPlayerSource { + return source + } + + override fun getDRMManager(source: NativeVideoPlayerSource): DRMManagerSpec? { return null } + + override fun getMediaDataSourceFactory( + source: NativeVideoPlayerSource, + mediaDataSourceFactory: DataSource.Factory + ): DataSource.Factory? { + return null + } + + override fun getMediaSourceFactory( + source: NativeVideoPlayerSource, + mediaSourceFactory: MediaSource.Factory, + mediaDataSourceFactory: DataSource.Factory + ): MediaSource.Factory? { + return null + } + + override fun getMediaItemBuilder( + source: NativeVideoPlayerSource, + mediaItemBuilder: MediaItem.Builder + ): MediaItem.Builder? { + return null + } + + override fun shouldDisableCache(source: NativeVideoPlayerSource): Boolean { + return false + } +} \ No newline at end of file diff --git a/android/src/main/java/com/twg/video/core/recivers/AudioBecomingNoisyReceiver.kt b/android/src/main/java/com/twg/video/core/recivers/AudioBecomingNoisyReceiver.kt new file mode 100644 index 00000000..1669bcf0 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/recivers/AudioBecomingNoisyReceiver.kt @@ -0,0 +1,38 @@ +package com.twg.video.core.recivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import androidx.core.content.ContextCompat +import com.margelo.nitro.NitroModules +import com.margelo.nitro.video.HybridVideoPlayerEventEmitter +import com.twg.video.core.LibraryError + +class AudioBecomingNoisyReceiver() : BroadcastReceiver() { + private var eventEmitter: HybridVideoPlayerEventEmitter? = null + + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) { + eventEmitter?.onAudioBecomingNoisy() + } + } + + fun setEventEmitter(eventEmitter: HybridVideoPlayerEventEmitter) { + val context = NitroModules.applicationContext ?: throw LibraryError.ApplicationContextNotFound + + this.eventEmitter = eventEmitter + val intentFilter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + ContextCompat.registerReceiver( + context.applicationContext, + this, + intentFilter, + ContextCompat.RECEIVER_NOT_EXPORTED + ) + } + + fun removeEventEmitter() { + this.eventEmitter = null + } +} diff --git a/android/src/main/java/com/twg/video/core/services/playback/CustomMediaNotificationProvider.kt b/android/src/main/java/com/twg/video/core/services/playback/CustomMediaNotificationProvider.kt new file mode 100644 index 00000000..f333014c --- /dev/null +++ b/android/src/main/java/com/twg/video/core/services/playback/CustomMediaNotificationProvider.kt @@ -0,0 +1,151 @@ +package com.twg.video.core.services.playback + +import android.content.Context +import android.app.PendingIntent +import android.content.Intent +import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.MediaSession +import com.google.common.collect.ImmutableList +import androidx.core.os.bundleOf +import android.os.Bundle +import com.margelo.nitro.NitroModules +import com.twg.video.core.LibraryError + +@OptIn(UnstableApi::class) +class CustomMediaNotificationProvider(context: Context) : DefaultMediaNotificationProvider(context) { + + init { + setSmallIcon(androidx.media3.session.R.drawable.media3_notification_small_icon) + } + + fun getContext(): Context { + return NitroModules.applicationContext ?: run { + throw LibraryError.ApplicationContextNotFound + } + } + + override fun getNotificationContentTitle(metadata: MediaMetadata): CharSequence? { + return metadata.title + ?: metadata.displayTitle + ?: metadata.subtitle + ?: metadata.description + ?: "${getAppName()} is playing" + } + + override fun getNotificationContentText(metadata: MediaMetadata): CharSequence? { + return metadata.artist + ?: metadata.subtitle + ?: metadata.description + } + + companion object { + private const val SEEK_INTERVAL_MS = 10000L + private const val TAG = "CustomMediaNotificationProvider" + + enum class COMMAND(val stringValue: String) { + NONE("NONE"), + SEEK_FORWARD("COMMAND_SEEK_FORWARD"), + SEEK_BACKWARD("COMMAND_SEEK_BACKWARD"), + TOGGLE_PLAY("COMMAND_TOGGLE_PLAY"), + PLAY("COMMAND_PLAY"), + PAUSE("COMMAND_PAUSE") + } + + fun commandFromString(value: String): COMMAND = + when (value) { + COMMAND.SEEK_FORWARD.stringValue -> COMMAND.SEEK_FORWARD + COMMAND.SEEK_BACKWARD.stringValue -> COMMAND.SEEK_BACKWARD + COMMAND.TOGGLE_PLAY.stringValue -> COMMAND.TOGGLE_PLAY + COMMAND.PLAY.stringValue -> COMMAND.PLAY + COMMAND.PAUSE.stringValue -> COMMAND.PAUSE + else -> COMMAND.NONE + } + + fun handleCommand(command: COMMAND, session: MediaSession) { + // TODO: get somehow ControlsConfig here - for now hardcoded 10000ms + + when (command) { + COMMAND.SEEK_BACKWARD -> session.player.seekTo(session.player.contentPosition - SEEK_INTERVAL_MS) + COMMAND.SEEK_FORWARD -> session.player.seekTo(session.player.contentPosition + SEEK_INTERVAL_MS) + COMMAND.TOGGLE_PLAY -> handleCommand(if (session.player.isPlaying) COMMAND.PAUSE else COMMAND.PLAY, session) + COMMAND.PLAY -> session.player.play() + COMMAND.PAUSE -> session.player.pause() + else -> Log.w(TAG, "Received COMMAND.NONE - was there an error?") + } + } + } + + private fun getAppName(): String { + return try { + val context = getContext() + val applicationInfo = context.applicationInfo + val labelRes = applicationInfo.labelRes + if (labelRes != 0) { + context.getString(labelRes) + } else { + applicationInfo.nonLocalizedLabel?.toString() ?: context.packageManager.getApplicationLabel(applicationInfo).toString() + } + } catch (e: Exception) { + return "Unknown" + } + } + + override fun getMediaButtons( + session: MediaSession, + playerCommands: Player.Commands, + mediaButtonPreferences: ImmutableList, + showPauseButton: Boolean + ): ImmutableList { + val rewind = CommandButton.Builder() + .setDisplayName("Rewind") + .setSessionCommand(androidx.media3.session.SessionCommand( + COMMAND.SEEK_BACKWARD.stringValue, + Bundle.EMPTY + )) + .setIconResId(androidx.media3.session.R.drawable.media3_icon_skip_back_10) + .setExtras(bundleOf(COMMAND_KEY_COMPACT_VIEW_INDEX to 0)) + .build() + + val toggle = CommandButton.Builder() + .setDisplayName(if (showPauseButton) "Pause" else "Play") + .setSessionCommand(androidx.media3.session.SessionCommand( + COMMAND.TOGGLE_PLAY.stringValue, + Bundle.EMPTY + )) + .setIconResId( + if (showPauseButton) androidx.media3.session.R.drawable.media3_icon_pause + else androidx.media3.session.R.drawable.media3_icon_play + ) + .setExtras(bundleOf(COMMAND_KEY_COMPACT_VIEW_INDEX to 1)) + .build() + + val forward = CommandButton.Builder() + .setDisplayName("Forward") + .setSessionCommand(androidx.media3.session.SessionCommand( + COMMAND.SEEK_FORWARD.stringValue, + Bundle.EMPTY + )) + .setIconResId(androidx.media3.session.R.drawable.media3_icon_skip_forward_10) + .setExtras(bundleOf(COMMAND_KEY_COMPACT_VIEW_INDEX to 2)) + .build() + + return ImmutableList.of(rewind, toggle, forward) + } + + override fun addNotificationActions( + mediaSession: MediaSession, + mediaButtons: ImmutableList, + builder: androidx.core.app.NotificationCompat.Builder, + actionFactory: androidx.media3.session.MediaNotification.ActionFactory + ): IntArray { + // Use default behavior to add actions from our custom buttons and return compact indices + val compact = super.addNotificationActions(mediaSession, mediaButtons, builder, actionFactory) + return if (compact.isEmpty()) intArrayOf(0, 1, 2) else compact + } +} diff --git a/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackCallback.kt b/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackCallback.kt new file mode 100644 index 00000000..25349a5d --- /dev/null +++ b/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackCallback.kt @@ -0,0 +1,96 @@ +package com.twg.video.core.services.playback + +import android.os.Bundle +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.CommandButton +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.collect.ImmutableList + +@OptIn(UnstableApi::class) +class VideoPlaybackCallback : MediaSession.Callback { + // For Android 13+ + private fun buildCustomButtons(): ImmutableList { + val rewind = CommandButton.Builder() + .setDisplayName("Rewind") + .setSessionCommand( + SessionCommand( + CustomMediaNotificationProvider.Companion.COMMAND.SEEK_BACKWARD.stringValue, + Bundle.EMPTY + ) + ) + .setIconResId(androidx.media3.session.R.drawable.media3_icon_skip_back_10) + .build() + + val forward = CommandButton.Builder() + .setDisplayName("Forward") + .setSessionCommand( + SessionCommand( + CustomMediaNotificationProvider.Companion.COMMAND.SEEK_FORWARD.stringValue, + Bundle.EMPTY + ) + ) + .setIconResId(androidx.media3.session.R.drawable.media3_icon_skip_forward_10) + .build() + + return ImmutableList.of(rewind, forward) + } + + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { + try { + return MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailablePlayerCommands( + MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() + .add(Player.COMMAND_SEEK_FORWARD) + .add(Player.COMMAND_SEEK_BACK) + .build() + ).setAvailableSessionCommands( + MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() + .add( + SessionCommand( + CustomMediaNotificationProvider.Companion.COMMAND.SEEK_FORWARD.stringValue, + Bundle.EMPTY + ) + ) + .add( + SessionCommand( + CustomMediaNotificationProvider.Companion.COMMAND.SEEK_BACKWARD.stringValue, + Bundle.EMPTY + ) + ) + .add( + SessionCommand( + CustomMediaNotificationProvider.Companion.COMMAND.TOGGLE_PLAY.stringValue, + Bundle.EMPTY + ) + ).build() + ).build() + } catch (e: Exception) { + return MediaSession.ConnectionResult.reject() + } + } + + override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { + session.setCustomLayout(buildCustomButtons()) + super.onPostConnect(session, controller) + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + CustomMediaNotificationProvider.Companion.handleCommand( + CustomMediaNotificationProvider.Companion.commandFromString( + customCommand.customAction + ), session + ) + + return super.onCustomCommand(session, controller, customCommand, args) + } +} diff --git a/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackService.kt b/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackService.kt new file mode 100644 index 00000000..a9e53165 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackService.kt @@ -0,0 +1,219 @@ +package com.twg.video.core.services.playback + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.os.Binder +import android.os.IBinder +import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.util.BitmapLoader +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.SimpleBitmapLoader +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.os.Build +import androidx.core.app.NotificationCompat +import com.margelo.nitro.NitroModules +import com.margelo.nitro.video.HybridVideoPlayer +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +class VideoPlaybackServiceBinder(val service: VideoPlaybackService): Binder() + +@OptIn(UnstableApi::class) +class VideoPlaybackService : MediaSessionService() { + private var mediaSessionsList = mutableMapOf() + private var binder = VideoPlaybackServiceBinder(this) + private var sourceActivity: Class? = null // retained for future deep-links; currently unused + private var isForeground = false + private var cachedLaunchIntent: Intent? = null + + override fun onCreate() { + super.onCreate() + setMediaNotificationProvider(CustomMediaNotificationProvider(this)) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Ensure we call startForeground quickly on newer Android versions to avoid + // ForegroundServiceDidNotStartInTimeException when startForegroundService(...) was used. + try { + if (!isForeground && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForeground(PLACEHOLDER_NOTIFICATION_ID, createPlaceholderNotification()) + isForeground = true + } + } catch (_: Exception) { + Log.e(TAG, "Failed to start foreground service!") + } + + return super.onStartCommand(intent, flags, startId) + } + + // Player Registry + fun registerPlayer(player: HybridVideoPlayer, from: Class) { + if (mediaSessionsList.containsKey(player)) { + return + } + sourceActivity = from + + val builder = MediaSession.Builder(this, player.player) + .setId("RNVideoPlaybackService_" + player.hashCode()) + .setCallback(VideoPlaybackCallback()) + + // Ensure tapping the notification opens the app via sessionActivity + try { + var launchIntent = cachedLaunchIntent + if (launchIntent == null) { + launchIntent = packageManager.getLaunchIntentForPackage(packageName) + cachedLaunchIntent = launchIntent + } + + if (launchIntent != null) { + // Clone the intent before modifying it to avoid mutating the cached instance + val intentToUse = launchIntent.clone() as Intent + intentToUse.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + val contentIntent = PendingIntent.getActivity( + this, + 0, + intentToUse, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + builder.setSessionActivity(contentIntent) + } + } catch (_: Exception) {} + + + val mediaSession = builder.build() + + mediaSessionsList[player] = mediaSession + addSession(mediaSession) + } + + fun unregisterPlayer(player: HybridVideoPlayer) { + val session = mediaSessionsList.remove(player) + session?.release() + stopIfNoPlayers() + } + + fun updatePlayerPreferences(player: HybridVideoPlayer) { + val session = mediaSessionsList[player] + if (session == null) { + // If not registered but now needs it, register + if (player.playInBackground || player.showNotificationControls) { + val activity = try { NitroModules.applicationContext?.currentActivity } catch (_: Exception) { null } + if (activity != null) registerPlayer(player, activity.javaClass) + } + return + } + + // If no longer needs registration, unregister and possibly stop service + if (!player.playInBackground && !player.showNotificationControls) { + unregisterPlayer(player) + stopIfNoPlayers() + return + } + } + + // Callbacks + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = null + + override fun onBind(intent: Intent?): IBinder { + super.onBind(intent) + return binder + } + + override fun onTaskRemoved(rootIntent: Intent?) { + stopForegroundSafely() + cleanup() + stopSelf() + } + + override fun onDestroy() { + stopForegroundSafely() + cleanup() + super.onDestroy() + } + + private fun stopForegroundSafely() { + try { + stopForeground(STOP_FOREGROUND_REMOVE) + } catch (_: Exception) { + Log.e(TAG, "Failed to stop foreground service!") + } + } + + private fun cleanup() { + stopForegroundSafely() + stopSelf() + mediaSessionsList.forEach { (_, session) -> + session.release() + } + mediaSessionsList.clear() + } + + // Stop the service if there are no active media sessions (no players need it) + fun stopIfNoPlayers() { + if (mediaSessionsList.isEmpty()) { + // Remove placeholder notification and stop the service when no active players exist + try { + if (isForeground) { + stopForegroundSafely() + isForeground = false + } + } catch (_: Exception) { + Log.e(TAG, "Failed to stop foreground service!") + } + cleanup() + } + } + + companion object { + const val TAG = "VideoPlaybackService" + const val VIDEO_PLAYBACK_SERVICE_INTERFACE = SERVICE_INTERFACE + private const val PLACEHOLDER_NOTIFICATION_ID = 1729 + private const val NOTIFICATION_CHANNEL_ID = "twg_video_playback" + } + + private fun createPlaceholderNotification(): Notification { + val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + if (nm.getNotificationChannel(NOTIFICATION_CHANNEL_ID) == null) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "Media playback", + NotificationManager.IMPORTANCE_LOW + ) + channel.setShowBadge(false) + nm.createNotificationChannel(channel) + } + } catch (_: Exception) { + Log.e(TAG, "Failed to create notification channel!") + } + } + + val appName = try { + val labelRes = applicationInfo.labelRes + if (labelRes != 0) { + getString(labelRes) + } else { + applicationInfo.nonLocalizedLabel?.toString() ?: applicationInfo.loadLabel(packageManager).toString() + } + } catch (_: Exception) { + "Media Playback" + } + + return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_media_play) + .setContentTitle(appName) + .setContentText("") + .setOngoing(true) + .setCategory(Notification.CATEGORY_SERVICE) + .build() + } +} diff --git a/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackServiceConnection.kt b/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackServiceConnection.kt new file mode 100644 index 00000000..ecc621f0 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackServiceConnection.kt @@ -0,0 +1,59 @@ +package com.twg.video.core.services.playback + +import android.content.ComponentName +import android.content.ServiceConnection +import android.os.IBinder +import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import com.margelo.nitro.NitroModules +import com.margelo.nitro.video.HybridVideoPlayer +import java.lang.ref.WeakReference + +@OptIn(UnstableApi::class) +class VideoPlaybackServiceConnection (private val player: WeakReference) : + ServiceConnection { + var serviceBinder: VideoPlaybackServiceBinder? = null + + override fun onServiceConnected(componentName: ComponentName?, binder: IBinder?) { + val player = player.get() ?: return + + try { + val activity = NitroModules.Companion.applicationContext?.currentActivity ?: run { + Log.e("VideoPlaybackServiceConnection", "Activity is null") + return + } + + serviceBinder = binder as? VideoPlaybackServiceBinder + // Only register when the player actually needs background service/notification + if (player.playInBackground || player.showNotificationControls) { + serviceBinder?.service?.registerPlayer(player, activity.javaClass) + } + } catch (err: Exception) { + Log.e("VideoPlaybackServiceConnection", "Could not bind to playback service", err) + } + } + + override fun onServiceDisconnected(componentName: ComponentName?) { + player.get()?.let { + unregisterPlayer(it) + } + serviceBinder = null + } + + override fun onNullBinding(componentName: ComponentName?) { + Log.e( + "VideoPlaybackServiceConnection", + "Could not bind to playback service - there can be issues with background playback" + + "and notification controls" + ) + } + + fun unregisterPlayer(player: HybridVideoPlayer) { + try { + if (serviceBinder?.service != null) { + serviceBinder?.service?.unregisterPlayer(player) + } + } catch (_: Exception) {} + } +} diff --git a/android/src/main/java/com/twg/video/core/utils/PictureInPictureUtils.kt b/android/src/main/java/com/twg/video/core/utils/PictureInPictureUtils.kt new file mode 100644 index 00000000..6b18b22e --- /dev/null +++ b/android/src/main/java/com/twg/video/core/utils/PictureInPictureUtils.kt @@ -0,0 +1,153 @@ +package com.twg.video.core.utils + +import android.app.PictureInPictureParams +import android.content.pm.PackageManager +import android.graphics.Rect +import android.os.Build +import android.util.Log +import android.util.Rational +import android.view.View +import androidx.annotation.OptIn +import androidx.annotation.RequiresApi +import androidx.media3.common.util.UnstableApi +import com.margelo.nitro.NitroModules +import com.twg.video.view.VideoView + +@OptIn(UnstableApi::class) +object PictureInPictureUtils { + private const val TAG = "PictureInPictureUtils" + + fun canEnterPictureInPicture(): Boolean { + val applicationContent = NitroModules.applicationContext + val currentActivity = applicationContent?.currentActivity + return currentActivity?.packageManager?.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) == true + } + + @RequiresApi(Build.VERSION_CODES.O) + fun createPictureInPictureParams(videoView: VideoView): PictureInPictureParams { + val builder = PictureInPictureParams.Builder() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && videoView.autoEnterPictureInPicture) { + builder.setAutoEnterEnabled(videoView.autoEnterPictureInPicture) + } + + return builder + .setAspectRatio(calculateAspectRatio(videoView)) + .setSourceRectHint(calculateSourceRectHint(videoView.playerView)) + .build() + } + + @RequiresApi(Build.VERSION_CODES.O) + fun createDisabledPictureInPictureParams(videoView: VideoView): PictureInPictureParams { + val defaultParams = PictureInPictureParams.Builder() + .setAspectRatio(null) // Clear aspect ratio + .setSourceRectHint(null) // Clear source rect hint + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + defaultParams.setAutoEnterEnabled(false) + } + + return defaultParams.build() + } + + fun calculateAspectRatio(videoView: VideoView): Rational { + // AspectRatio for PIP must be between 2.39:1 and 1:2.39 + // see: https://developer.android.com/reference/android/app/PictureInPictureParams.Builder#setAspectRatio(android.util.Rational) + + val maximumAspectRatio = Rational(239, 100) + val minimumAspectRatio = Rational(100, 239) + + val player = videoView.hybridPlayer?.player + val videoFormat = player?.videoFormat + + var width: Int + var height: Int + + if (videoFormat != null && videoFormat.width > 0 && videoFormat.height > 0) { + width = videoFormat.width + height = videoFormat.height + + val rotationDegrees = videoFormat.rotationDegrees + if (rotationDegrees == 90 || rotationDegrees == 270) { + val temp = width + width = height + height = temp + } + + Log.d(TAG, "Using video format dimensions for PiP: ${width}x${height} (rotation: ${rotationDegrees}°)") + } else { + width = videoView.playerView.width + height = videoView.playerView.height + Log.d(TAG, "Using view dimensions for PiP: ${width}x${height}") + } + + val currentAspectRatio = Rational(width, height) + + return when { + currentAspectRatio > maximumAspectRatio -> maximumAspectRatio + currentAspectRatio < minimumAspectRatio -> minimumAspectRatio + else -> currentAspectRatio + } + } + + fun calculateSourceRectHint(view: View): Rect { + // Get the visible rectangle of view in screen coordinates + val visibleRect = Rect() + view.getGlobalVisibleRect(visibleRect) + + // Get the Y position of view on the screen + val locationOnScreen = IntArray(2) + view.getLocationOnScreen(locationOnScreen) + val yOnScreen = locationOnScreen[1] + + // Preserve the original height + val height = visibleRect.height() + + // Set the new top and bottom based on the view's screen position + visibleRect.top = yOnScreen + visibleRect.bottom = yOnScreen + height + + return visibleRect + } + + fun safeSetPictureInPictureParams(params: PictureInPictureParams) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + val currentActivity = NitroModules.applicationContext?.currentActivity + currentActivity?.setPictureInPictureParams(params) + Log.d(TAG, "Successfully set PiP params") + } catch (e: Exception) { + Log.w(TAG, "Failed to set PiP params - PiP may not be enabled in manifest", e) + // Ignore: We cannot check if user has added support for PIP in manifest + // so we need to catch error if he did not add it. + } + } + } + + @RequiresApi(Build.VERSION_CODES.O) + fun safeEnterPictureInPictureMode(params: PictureInPictureParams): Boolean { + return try { + val currentActivity = NitroModules.applicationContext?.currentActivity + val result = currentActivity?.enterPictureInPictureMode(params) ?: false + Log.d(TAG, "PiP enter result: $result") + result + } catch (e: Exception) { + Log.e(TAG, "Failed to enter PiP mode", e) + false + } + } + + fun isCurrentlyInPictureInPictureMode(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + val currentActivity = NitroModules.applicationContext?.currentActivity + currentActivity?.isInPictureInPictureMode == true + } catch (e: Exception) { + Log.w(TAG, "Failed to check PiP mode status", e) + false + } + } else { + false + } + } +} diff --git a/android/src/main/java/com/twg/video/core/utils/SmallVideoPlayerOptimizer.kt b/android/src/main/java/com/twg/video/core/utils/SmallVideoPlayerOptimizer.kt new file mode 100644 index 00000000..dfcba4ed --- /dev/null +++ b/android/src/main/java/com/twg/video/core/utils/SmallVideoPlayerOptimizer.kt @@ -0,0 +1,157 @@ +package com.twg.video.core.utils + +import android.content.Context +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import androidx.media3.ui.PlayerView + +object SmallVideoPlayerOptimizer { + + fun isSmallVideoPlayer(playerView: PlayerView): Boolean { + // Check if the PlayerView dimensions are small enough to warrant optimizations + val width = playerView.width + val height = playerView.height + + // If view hasn't been measured yet, use layout params or return false + if (width <= 0 || height <= 0) { + val layoutParams = playerView.layoutParams + if (layoutParams != null) { + // Convert any specific dimensions to pixels if needed + val widthPx = if (layoutParams.width > 0) layoutParams.width else playerView.measuredWidth + val heightPx = if (layoutParams.height > 0) layoutParams.height else playerView.measuredHeight + + if (widthPx <= 0 || heightPx <= 0) return false + + return isSmallDimensions(widthPx, heightPx, playerView.context) + } + return false + } + + return isSmallDimensions(width, height, playerView.context) + } + + private fun isSmallDimensions(widthPx: Int, heightPx: Int, context: Context): Boolean { + val density = context.resources.displayMetrics.density + val widthDp = widthPx / density + val heightDp = heightPx / density + + // Consider the video player "small" if width <= 400dp or height <= 300dp + // These thresholds are more appropriate for actual video player sizes + return widthDp <= 400 || heightDp <= 300 + } + + fun applyOptimizations( + playerView: PlayerView, + context: Context, + isFullscreen: Boolean = false + ) { + playerView.post { + try { + if (isFullscreen) { + // For fullscreen mode, use system defaults - no custom optimizations + // Let ExoPlayer use its default controller timeout and styling + return@post + } + + // Only apply optimizations if the video player itself is small + if (!isSmallVideoPlayer(playerView)) { + return@post + } + + val controllerView = playerView.findViewById(androidx.media3.ui.R.id.exo_controller) + controllerView?.let { controller -> + optimizeControlElementsForSmallPlayer(controller, context) + } + } catch (e: Exception) { + Log.w("ReactNativeVideo", "Error applying small video player optimizations: ${e.message}") + } + } + } + + private fun optimizeControlElementsForSmallPlayer( + controller: ViewGroup, + context: Context + ) { + val density = context.resources.displayMetrics.density + val primaryButtonSize = (48 * density).toInt() + val secondaryButtonSize = (44 * density).toInt() + + optimizeButtons(controller, primaryButtonSize, secondaryButtonSize) + optimizeProgressBar(controller, context) + optimizeTextElements(controller) + } + + private fun optimizeButtons( + container: ViewGroup, + primarySize: Int, + secondarySize: Int + ) { + for (i in 0 until container.childCount) { + val child = container.getChildAt(i) + when (child) { + is ImageButton -> { + val buttonSize = when (child.id) { + androidx.media3.ui.R.id.exo_play_pause -> primarySize + androidx.media3.ui.R.id.exo_fullscreen -> primarySize + androidx.media3.ui.R.id.exo_settings -> primarySize + androidx.media3.ui.R.id.exo_rew -> secondarySize + androidx.media3.ui.R.id.exo_ffwd -> secondarySize + androidx.media3.ui.R.id.exo_subtitle -> secondarySize + androidx.media3.ui.R.id.exo_prev -> secondarySize + androidx.media3.ui.R.id.exo_next -> secondarySize + else -> secondarySize + } + + val params = child.layoutParams + params.width = buttonSize + params.height = buttonSize + child.layoutParams = params + + // Hide less essential buttons on small video players + when (child.id) { + androidx.media3.ui.R.id.exo_shuffle, + androidx.media3.ui.R.id.exo_repeat_toggle, + androidx.media3.ui.R.id.exo_vr -> { + child.visibility = View.GONE + } + } + } + is ViewGroup -> { + optimizeButtons(child, primarySize, secondarySize) + } + } + } + } + + private fun optimizeProgressBar( + controller: ViewGroup, + context: Context + ) { + val progressContainer = controller.findViewById(androidx.media3.ui.R.id.exo_progress) + progressContainer?.let { progress -> + val params = progress.layoutParams as? ViewGroup.MarginLayoutParams + params?.let { + it.height = (4 * context.resources.displayMetrics.density).toInt() + progress.layoutParams = it + } + } + } + + private fun optimizeTextElements( + controller: ViewGroup + ) { + val timeContainer = controller.findViewById(androidx.media3.ui.R.id.exo_time) + timeContainer?.let { time -> + val positionView = time.findViewById(androidx.media3.ui.R.id.exo_position) + val durationView = time.findViewById(androidx.media3.ui.R.id.exo_duration) + + listOf(positionView, durationView).forEach { textView -> + if (textView is android.widget.TextView) { + textView.textSize = 12f + } + } + } + } +} diff --git a/android/src/main/java/com/twg/video/core/utils/SourceLoader.kt b/android/src/main/java/com/twg/video/core/utils/SourceLoader.kt new file mode 100644 index 00000000..fced3291 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/utils/SourceLoader.kt @@ -0,0 +1,48 @@ +package com.twg.video.core.utils + +import com.twg.video.core.SourceError +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +class SourceLoader { + private val operationCounter = AtomicInteger(0) + private val currentOperationRef = AtomicReference?>(null) + + fun load(operation: () -> T): T { + cancel() + + val currentOp = operationCounter.incrementAndGet() + val holder = OperationHolder(currentOp) + currentOperationRef.set(holder) + + return try { + val result = operation() + if (holder.operationId != operationCounter.get()) { + throw SourceError.Cancelled + } + result + } catch (e: SourceError.Cancelled) { + throw e + } catch (e: Exception) { + if (holder.operationId != operationCounter.get()) { + throw SourceError.Cancelled + } + throw e + } finally { + if (holder.operationId == operationCounter.get()) { + currentOperationRef.set(null) + } + } + } + + fun cancel() { + val current = currentOperationRef.get() + if (current != null) { + operationCounter.incrementAndGet() + currentOperationRef.set(null) + } + } + + private class OperationHolder(val operationId: Int) +} + diff --git a/android/src/main/java/com/twg/video/core/utils/TextTrackUtils.kt b/android/src/main/java/com/twg/video/core/utils/TextTrackUtils.kt new file mode 100644 index 00000000..5d658626 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/utils/TextTrackUtils.kt @@ -0,0 +1,175 @@ +package com.twg.video.core.utils + +import androidx.media3.common.C +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import com.margelo.nitro.video.HybridVideoPlayerSourceSpec +import com.margelo.nitro.video.TextTrack + +@UnstableApi +object TextTrackUtils { + fun getAvailableTextTracks(player: ExoPlayer, source: HybridVideoPlayerSourceSpec): Array { + return Threading.runOnMainThreadSync { + val tracks = mutableListOf() + val currentTracks = player.currentTracks + var globalTrackIndex = 0 + + // Get all text tracks from the current player tracks (includes both built-in and external) + for (trackGroup in currentTracks.groups) { + if (trackGroup.type == C.TRACK_TYPE_TEXT) { + for (trackIndex in 0 until trackGroup.length) { + val format = trackGroup.getTrackFormat(trackIndex) + val trackId = format.id ?: "text-$globalTrackIndex" + val label = format.label ?: "Unknown ${globalTrackIndex + 1}" + val language = format.language + val isSelected = trackGroup.isTrackSelected(trackIndex) + + // Determine if this is an external track by checking if it matches external subtitle labels + val isExternal = source.config.externalSubtitles?.any { subtitle -> + label.contains(subtitle.label, ignoreCase = true) + } == true + + val finalTrackId = if (isExternal) "external-$globalTrackIndex" else trackId + + tracks.add( + TextTrack( + id = finalTrackId, + label = label, + language = language, + selected = isSelected + ) + ) + + globalTrackIndex++ + } + } + } + + tracks.toTypedArray() + } + } + + fun selectTextTrack( + player: ExoPlayer, + textTrack: TextTrack?, + source: HybridVideoPlayerSourceSpec, + onTrackChange: (TextTrack?) -> Unit, + ): Int? { + return Threading.runOnMainThreadSync { + val trackSelector = player.trackSelectionParameters.buildUpon() + + // If textTrack is null, disable all text tracks + if (textTrack == null) { + trackSelector.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) + player.trackSelectionParameters = trackSelector.build() + onTrackChange(null) + return@runOnMainThreadSync null + } + + if (textTrack.id.isEmpty()) { + // Disable all text tracks + trackSelector.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) + player.trackSelectionParameters = trackSelector.build() + onTrackChange(null) + return@runOnMainThreadSync null + } + + val currentTracks = player.currentTracks + var trackFound = false + var selectedExternalTrackIndex: Int? = null + var globalTrackIndex = 0 + + // Find and select the specific text track + for (trackGroup in currentTracks.groups) { + if (trackGroup.type == C.TRACK_TYPE_TEXT) { + for (trackIndex in 0 until trackGroup.length) { + val format = trackGroup.getTrackFormat(trackIndex) + val currentTrackId = format.id ?: "text-$globalTrackIndex" + val label = format.label ?: "Unknown ${globalTrackIndex + 1}" + + // Check if this matches our target track (either by original ID or by external ID) + val isExternal = source.config.externalSubtitles?.any { subtitle -> + label.contains(subtitle.label, ignoreCase = true) + } == true + + val finalTrackId = + if (isExternal) "external-$globalTrackIndex" else currentTrackId + + if (finalTrackId == textTrack.id) { + // Enable this specific track + trackSelector.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + trackSelector.setOverrideForType( + TrackSelectionOverride( + trackGroup.mediaTrackGroup, + listOf(trackIndex) + ) + ) + + // Update selection state + selectedExternalTrackIndex = if (isExternal) { + globalTrackIndex + } else { + null + } + + onTrackChange(textTrack) + trackFound = true + break + } + + globalTrackIndex++ + } + if (trackFound) { + break + } + } + } + + // Apply the track selection parameters regardless of whether we found a track + player.trackSelectionParameters = trackSelector.build() + selectedExternalTrackIndex + } + } + + fun getSelectedTrack(player: ExoPlayer, source: HybridVideoPlayerSourceSpec): TextTrack? { + return Threading.runOnMainThreadSync { + val currentTracks = player.currentTracks + var globalTrackIndex = 0 + + // Find the currently selected text track + for (trackGroup in currentTracks.groups) { + if (trackGroup.type == C.TRACK_TYPE_TEXT && trackGroup.isSelected) { + for (trackIndex in 0 until trackGroup.length) { + if (trackGroup.isTrackSelected(trackIndex)) { + val format = trackGroup.getTrackFormat(trackIndex) + val trackId = format.id ?: "text-$globalTrackIndex" + val label = format.label ?: "Unknown ${globalTrackIndex + 1}" + val language = format.language + + // Determine if this is an external track by checking if it matches external subtitle labels + val isExternal = source.config.externalSubtitles?.any { subtitle -> + label.contains(subtitle.label, ignoreCase = true) + } == true + + val finalTrackId = if (isExternal) "external-$globalTrackIndex" else trackId + + return@runOnMainThreadSync TextTrack( + id = finalTrackId, + label = label, + language = language, + selected = true + ) + } + globalTrackIndex++ + } + } else if (trackGroup.type == C.TRACK_TYPE_TEXT) { + // Still need to increment global index for non-selected text track groups + globalTrackIndex += trackGroup.length + } + } + + null + } + } +} diff --git a/android/src/main/java/com/twg/video/core/utils/Threading.kt b/android/src/main/java/com/twg/video/core/utils/Threading.kt new file mode 100644 index 00000000..21f0e6d2 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/utils/Threading.kt @@ -0,0 +1,75 @@ +package com.twg.video.core.utils + +import android.os.Handler +import android.os.Looper +import com.margelo.nitro.NitroModules +import com.twg.video.core.LibraryError +import java.util.concurrent.Callable +import java.util.concurrent.FutureTask +import kotlin.reflect.KProperty + +object Threading { + @JvmStatic + fun runOnMainThread(action: () -> Unit) { + // We are already on the main thread, run and return + if (Looper.myLooper() == Looper.getMainLooper()) { + action() + return + } + + // If application context is null, throw an error + if (NitroModules.applicationContext == null) { + throw LibraryError.ApplicationContextNotFound + } + + // Post the action to the main thread + Handler(NitroModules.applicationContext!!.mainLooper).post { + action() + } + } + + @JvmStatic + fun runOnMainThreadSync(action: Callable): T { + return if (Looper.myLooper() == Looper.getMainLooper()) { + // Already on the main thread, run and return the result + action.call() + } else { + // Post the action to the main thread and wait for the result + val futureTask = FutureTask(action) + Handler(Looper.getMainLooper()).post(futureTask) + futureTask.get() + } + } + + class MainThreadProperty( + private val get: Reference.() -> Type, + private val set: (Reference.(Type) -> Unit)? = null + ) { + operator fun getValue(thisRef: Reference, property: KProperty<*>): Type { + return runOnMainThreadSync { thisRef.get() } + } + + operator fun setValue(thisRef: Reference, property: KProperty<*>, value: Type) { + val setter = set ?: throw IllegalStateException("Property ${property.name} is read-only") + runOnMainThread { thisRef.setter(value) } + } + } + + /** + * Read-only property that runs on main thread + * @param get The getter function that runs synchronously on the main thread. + * + * @throws [IllegalStateException] if there will be a write operation + */ + fun Reference.mainThreadProperty(get: Reference.() -> T) = MainThreadProperty(get) + + /** + * Read-only property that runs on main thread + * @param get The getter function that runs synchronously on the main thread + * @param set The setter function that runs asynchronously on the main thread + */ + fun Reference.mainThreadProperty( + get: Reference.() -> T, + set: Reference.(T) -> Unit + ) = MainThreadProperty(get, set) +} diff --git a/android/src/main/java/com/twg/video/core/utils/VideoFileHelper.kt b/android/src/main/java/com/twg/video/core/utils/VideoFileHelper.kt new file mode 100644 index 00000000..c12e97db --- /dev/null +++ b/android/src/main/java/com/twg/video/core/utils/VideoFileHelper.kt @@ -0,0 +1,51 @@ +package com.twg.video.core.utils + +import android.Manifest +import android.content.pm.PackageManager +import android.net.Uri +import android.webkit.URLUtil +import com.margelo.nitro.NitroModules +import com.twg.video.core.SourceError +import java.io.File +import java.net.URL +import java.net.URLConnection + +object VideoFileHelper { + private fun hasReadPermission(): Boolean { + return NitroModules.applicationContext?.checkSelfPermission( + Manifest.permission.READ_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED + } + + fun validateReadPermission(uri: String) { + if (!hasReadPermission()) throw SourceError.MissingReadFilePermission(uri) + + val file = File(Uri.parse(uri).path ?: throw SourceError.InvalidUri(uri)) + + // Check if file exists and is readable + if (!file.exists()) throw SourceError.FileDoesNotExist(uri) + + // Check if file is readable + if (!file.canRead()) throw SourceError.MissingReadFilePermission(uri) + } + + fun getFileSizeFromUri(uri: String): Long { + return try { + when { + URLUtil.isFileUrl(uri) -> { + validateReadPermission(uri) + val file = File(Uri.parse(uri).path ?: return -1) + if (file.exists()) file.length() else -1 + } + URLUtil.isNetworkUrl(uri) -> { + val connection: URLConnection = URL(uri).openConnection() + connection.connect() + connection.contentLength.toLong() + } + else -> -1 + } + } catch (e: Exception) { + -1 + } + } +} diff --git a/android/src/main/java/com/twg/video/core/utils/VideoInformationUtils.kt b/android/src/main/java/com/twg/video/core/utils/VideoInformationUtils.kt new file mode 100644 index 00000000..4d4f3f57 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/utils/VideoInformationUtils.kt @@ -0,0 +1,67 @@ +package com.twg.video.core.utils + +import android.media.MediaFormat +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Build +import android.webkit.URLUtil +import com.margelo.nitro.video.VideoInformation +import androidx.core.net.toUri + +object VideoInformationUtils { + fun fromUri(uri: String, headers: Map = emptyMap()): VideoInformation { + val retriever = MediaMetadataRetriever() + + when { + URLUtil.isFileUrl(uri) -> { + retriever.setDataSource(uri.toUri().path) + } + else -> { + retriever.setDataSource(uri, headers) + } + } + + // Get dimensions + val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toDoubleOrNull() ?: Double.NaN + val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toDoubleOrNull() ?: Double.NaN + + // Get duration in milliseconds + val duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toDoubleOrNull() ?: -1.0 + + // If we have some valid info, but there is no duration it might be live + val isLive = !width.isNaN() && !height.isNaN() && duration <= 0.0 + + // Get bitrate + val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)?.toDoubleOrNull() ?: Double.NaN + + // Get rotation + val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0 + + // Check for HDR by looking at color transfer (API 30+) + val isHDR = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val colorTransfer = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER)?.toIntOrNull() + colorTransfer == MediaFormat.COLOR_TRANSFER_ST2084 || colorTransfer == MediaFormat.COLOR_TRANSFER_HLG + } else { + false + } + + // Clean up + retriever.release() + + // Get file size + val fileSize = VideoFileHelper.getFileSizeFromUri(uri) + + val videoInfo = VideoInformation( + bitrate = bitrate, + width = width, + height = height, + duration = duration, + fileSize = fileSize, + isHDR = isHDR, + isLive = isLive, + orientation = VideoOrientationUtils.fromWHR(width.toInt(), height.toInt(), rotation) + ) + + return videoInfo + } +} diff --git a/android/src/main/java/com/twg/video/core/utils/VideoOrientationUtils.kt b/android/src/main/java/com/twg/video/core/utils/VideoOrientationUtils.kt new file mode 100644 index 00000000..7ebc12f9 --- /dev/null +++ b/android/src/main/java/com/twg/video/core/utils/VideoOrientationUtils.kt @@ -0,0 +1,30 @@ +package com.twg.video.core.utils + +import com.margelo.nitro.video.VideoOrientation + +object VideoOrientationUtils { + fun fromWHR(width: Int?, height: Int?, rotation: Int?): VideoOrientation { + if (width == 0 || height == 0 || height == null || width == null) return VideoOrientation.UNKNOWN + + if (width == height) return VideoOrientation.SQUARE + + // Check if video is portrait or landscape using natural size + val isNaturalSizePortrait = height > width + + // If rotation is not available, use natural size to determine orientation + if (rotation == null) { + return if (isNaturalSizePortrait) VideoOrientation.PORTRAIT else VideoOrientation.LANDSCAPE_RIGHT + } + + // Normalize rotation to 0-360 range + val normalizedRotation = ((rotation % 360) + 360) % 360 + + return when (normalizedRotation) { + 0 -> if (isNaturalSizePortrait) VideoOrientation.PORTRAIT else VideoOrientation.LANDSCAPE_RIGHT + 90 -> VideoOrientation.PORTRAIT + 180 -> if (isNaturalSizePortrait) VideoOrientation.PORTRAIT_UPSIDE_DOWN else VideoOrientation.LANDSCAPE_LEFT + 270 -> VideoOrientation.PORTRAIT_UPSIDE_DOWN + else -> if (isNaturalSizePortrait) VideoOrientation.PORTRAIT else VideoOrientation.LANDSCAPE_RIGHT + } + } +} diff --git a/android/src/main/java/com/twg/video/hybrids/videoplayer/HybridVideoPlayer.kt b/android/src/main/java/com/twg/video/hybrids/videoplayer/HybridVideoPlayer.kt new file mode 100644 index 00000000..25a99711 --- /dev/null +++ b/android/src/main/java/com/twg/video/hybrids/videoplayer/HybridVideoPlayer.kt @@ -0,0 +1,621 @@ +package com.margelo.nitro.video + +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.media3.common.C +import androidx.media3.common.Metadata +import androidx.media3.common.PlaybackException +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.Tracks +import androidx.media3.common.text.CueGroup +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.upstream.DefaultAllocator +import androidx.media3.extractor.metadata.emsg.EventMessage +import androidx.media3.extractor.metadata.id3.Id3Frame +import androidx.media3.extractor.metadata.id3.TextInformationFrame +import androidx.media3.ui.PlayerView +import com.facebook.proguard.annotations.DoNotStrip +import com.margelo.nitro.NitroModules +import com.margelo.nitro.core.Promise +import com.twg.video.core.LibraryError +import com.twg.video.core.PlayerError +import com.twg.video.core.VideoManager +import com.twg.video.core.extensions.startService +import com.twg.video.core.extensions.stopService +import com.twg.video.core.player.OnAudioFocusChangedListener +import com.twg.video.core.recivers.AudioBecomingNoisyReceiver +import com.twg.video.core.services.playback.VideoPlaybackService +import com.twg.video.core.services.playback.VideoPlaybackServiceConnection +import com.twg.video.core.utils.TextTrackUtils +import com.twg.video.core.utils.Threading.mainThreadProperty +import com.twg.video.core.utils.Threading.runOnMainThread +import com.twg.video.core.utils.Threading.runOnMainThreadSync +import com.twg.video.core.utils.VideoOrientationUtils +import com.twg.video.view.VideoView +import java.lang.ref.WeakReference +import kotlin.math.max + +@UnstableApi +@DoNotStrip +class HybridVideoPlayer() : HybridVideoPlayerSpec(), AutoCloseable { + override lateinit var source: HybridVideoPlayerSourceSpec + override var eventEmitter = HybridVideoPlayerEventEmitter() + set(value) { + if (field != value) { + audioFocusChangedListener.setEventEmitter(value) + audioBecomingNoisyReceiver.setEventEmitter(value) + } + field = value + } + + private var allocator: DefaultAllocator? = null + private var context = NitroModules.applicationContext + ?: run { + throw LibraryError.ApplicationContextNotFound + } + + var player: ExoPlayer = runOnMainThreadSync { + // Build Temporary player that will be replaced when source is loaded + return@runOnMainThreadSync ExoPlayer.Builder(context).build() + } + + var loadedWithSource = false + private var currentPlayerView: WeakReference? = null + + var wasAutoPaused = false + + // Buffer Config + private var bufferConfig: BufferConfig? = null + get() = source.config.bufferConfig + + // Time updates + private val progressHandler = Handler(Looper.getMainLooper()) + private var progressRunnable: Runnable? = null + + // Listeners + private val audioFocusChangedListener = OnAudioFocusChangedListener() + private val audioBecomingNoisyReceiver = AudioBecomingNoisyReceiver() + + // Service Connection + private val videoPlaybackServiceConnection = VideoPlaybackServiceConnection(WeakReference(this)) + + // Text track selection state + private var selectedExternalTrackIndex: Int? = null + + private companion object { + const val PROGRESS_UPDATE_INTERVAL_MS = 250L + private const val TAG = "HybridVideoPlayer" + private const val DEFAULT_MIN_BUFFER_DURATION_MS = 5000 + private const val DEFAULT_MAX_BUFFER_DURATION_MS = 10000 + private const val DEFAULT_BUFFER_FOR_PLAYBACK_DURATION_MS = 1000 + private const val DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_DURATION_MS = 2000 + private const val DEFAULT_BACK_BUFFER_DURATION_MS = 0 + } + + override var status: VideoPlayerStatus = VideoPlayerStatus.IDLE + set(value) { + if (field != value) { + eventEmitter.onStatusChange(value) + } + field = value + } + + override var showNotificationControls: Boolean = false + set(value) { + val wasRunning = (field || playInBackground) + val shouldRun = (value || playInBackground) + + if (shouldRun && !wasRunning) { + VideoPlaybackService.startService(context, videoPlaybackServiceConnection) + } + if (!shouldRun && wasRunning) { + VideoPlaybackService.stopService(this, videoPlaybackServiceConnection) + } + + field = value + // Inform service to refresh notification/session layout + try { videoPlaybackServiceConnection.serviceBinder?.service?.updatePlayerPreferences(this) } catch (_: Exception) {} + } + + // Player Properties + override var currentTime: Double by mainThreadProperty( + get = { player.currentPosition.toDouble() / 1000.0 }, + set = { value -> runOnMainThread { player.seekTo((value * 1000).toLong()) } } + ) + + // volume defined by user + var userVolume: Double = 1.0 + + override var volume: Double by mainThreadProperty( + get = { player.volume.toDouble() }, + set = { value -> + userVolume = value + player.volume = value.toFloat() + } + ) + + override val duration: Double by mainThreadProperty( + get = { + val duration = player.duration + return@mainThreadProperty if (duration == C.TIME_UNSET) Double.NaN else duration.toDouble() / 1000.0 + } + ) + + override var loop: Boolean by mainThreadProperty( + get = { + player.repeatMode == Player.REPEAT_MODE_ONE + }, + set = { value -> + player.repeatMode = if (value) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF + } + ) + + override var muted: Boolean by mainThreadProperty( + get = { + val playerVolume = player.volume.toDouble() + return@mainThreadProperty playerVolume == 0.0 + }, + set = { value -> + if (value) { + userVolume = volume + player.volume = 0f + } else { + player.volume = userVolume.toFloat() + } + eventEmitter.onVolumeChange(onVolumeChangeData( + volume = player.volume.toDouble(), + muted = muted + )) + } + ) + + override var rate: Double by mainThreadProperty( + get = { player.playbackParameters.speed.toDouble() }, + set = { value -> + player.playbackParameters = player.playbackParameters.withSpeed(value.toFloat()) + } + ) + + override var mixAudioMode: MixAudioMode = MixAudioMode.AUTO + set(value) { + VideoManager.audioFocusManager.requestAudioFocusUpdate() + field = value + } + + // iOS only property + override var ignoreSilentSwitchMode: IgnoreSilentSwitchMode = IgnoreSilentSwitchMode.AUTO + + override var playInBackground: Boolean = false + set(value) { + val shouldRun = (value || showNotificationControls) + val wasRunning = (field || showNotificationControls) + + if (shouldRun && !wasRunning) { + VideoPlaybackService.startService(context, videoPlaybackServiceConnection) + } + if (!shouldRun && wasRunning) { + VideoPlaybackService.stopService(this, videoPlaybackServiceConnection) + } + field = value + // Update preferences to refresh notifications/registration + try { videoPlaybackServiceConnection.serviceBinder?.service?.updatePlayerPreferences(this) } catch (_: Exception) {} + } + + override var playWhenInactive: Boolean = false + + override var isPlaying: Boolean by mainThreadProperty( + get = { player.isPlaying == true } + ) + + private fun initializePlayer() { + if (NitroModules.applicationContext == null) { + throw LibraryError.ApplicationContextNotFound + } + + val hybridSource = source as? HybridVideoPlayerSource ?: throw PlayerError.InvalidSource + + // Initialize the allocator + allocator = DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE) + + // Create a LoadControl with the allocator + val loadControl = DefaultLoadControl.Builder() + .setAllocator(allocator!!) + .setBufferDurationsMs( + bufferConfig?.minBufferMs?.toInt() ?: DEFAULT_MIN_BUFFER_DURATION_MS, // minBufferMs + bufferConfig?.maxBufferMs?.toInt() ?: DEFAULT_MAX_BUFFER_DURATION_MS, // maxBufferMs + bufferConfig?.bufferForPlaybackMs?.toInt() + ?: DEFAULT_BUFFER_FOR_PLAYBACK_DURATION_MS, // bufferForPlaybackMs + bufferConfig?.bufferForPlaybackAfterRebufferMs?.toInt() + ?: DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_DURATION_MS // bufferForPlaybackAfterRebufferMs + ) + .setBackBuffer( + bufferConfig?.backBufferDurationMs?.toInt() + ?: DEFAULT_BACK_BUFFER_DURATION_MS, // backBufferDurationMs, + false // retainBackBufferFromKeyframe + ) + .build() + + val renderersFactory = DefaultRenderersFactory(context) + .forceEnableMediaCodecAsynchronousQueueing() + .setEnableDecoderFallback(true) + + // Build the player with the LoadControl + player = ExoPlayer.Builder(context) + .setLoadControl(loadControl) + .setLooper(Looper.getMainLooper()) + .setRenderersFactory(renderersFactory) + .build() + + loadedWithSource = true + + player.addListener(playerListener) + player.addAnalyticsListener(analyticsListener) + player.setMediaSource(hybridSource.mediaSource) + + // Emit onLoadStart + val sourceType = if (hybridSource.uri.startsWith("http")) SourceType.NETWORK else SourceType.LOCAL + eventEmitter.onLoadStart(onLoadStartData(sourceType = sourceType, source = hybridSource)) + status = VideoPlayerStatus.LOADING + startProgressUpdates() + } + + override fun initialize(): Promise { + return Promise.async { + return@async runOnMainThreadSync { + initializePlayer() + player.prepare() + } + } + } + + constructor(source: HybridVideoPlayerSource) : this() { + this.source = source + + runOnMainThread { + if (source.config.initializeOnCreation == true) { + initializePlayer() + player.prepare() + } + } + + VideoManager.registerPlayer(this) + } + + override fun play() { + runOnMainThread { + player.play() + } + } + + override fun pause() { + runOnMainThread { + player.pause() + } + } + + override fun seekBy(time: Double) { + currentTime = (currentTime + time).coerceIn(0.0, duration) + } + + override fun seekTo(time: Double) { + currentTime = time.coerceIn(0.0, duration) + } + + override fun replaceSourceAsync(source: Variant_NullType_HybridVideoPlayerSourceSpec?): Promise { + return Promise.async { + val source = source?.asSecondOrNull() + + if (source == null) { + release() + return@async + } + + val hybridSource = source as? HybridVideoPlayerSource ?: throw PlayerError.InvalidSource + + val oldSource = this.source as? HybridVideoPlayerSource + oldSource?.sourceLoader?.cancel() + + runOnMainThreadSync { + // Update source + this.source = source + player.setMediaSource(hybridSource.mediaSource) + + // Prepare player + player.prepare() + } + } + } + + override fun preload(): Promise { + return Promise.async { + runOnMainThreadSync { + if (!loadedWithSource) { + initializePlayer() + } + + if (player.playbackState != Player.STATE_IDLE) { + return@runOnMainThreadSync + } + + player.prepare() + } + } + } + + override fun release() { + if (playInBackground || showNotificationControls) { + VideoPlaybackService.stopService(this, videoPlaybackServiceConnection) + } + + runOnMainThread { + VideoManager.unregisterPlayer(this) + stopProgressUpdates() + loadedWithSource = false + + eventEmitter.clearAllListeners() + + player.removeListener(playerListener) + player.removeAnalyticsListener(analyticsListener) + player.release() // Release player + + // Clean Listeners + audioFocusChangedListener.removeEventEmitter() + audioBecomingNoisyReceiver.removeEventEmitter() + + // Update status + status = VideoPlayerStatus.IDLE + } + } + + fun movePlayerToVideoView(videoView: VideoView) { + VideoManager.addViewToPlayer(videoView, this) + + runOnMainThreadSync { + PlayerView.switchTargetView(player, currentPlayerView?.get(), videoView.playerView) + currentPlayerView = WeakReference(videoView.playerView) + } + } + + override fun dispose() { + release() + } + + override fun close() { + release() + } + + override val memorySize: Long + // 1 MiB by default + get() = allocator?.totalBytesAllocated?.toLong() ?: (1024L * 1024L) + + private fun startProgressUpdates() { + stopProgressUpdates() // Ensure no multiple runnables + progressRunnable = object : Runnable { + override fun run() { + if (player.playbackState != Player.STATE_IDLE && player.playbackState != Player.STATE_ENDED) { + val currentTimeSeconds = player.currentPosition / 1000.0 + val bufferedDurationSeconds = player.bufferedPosition / 1000.0 + // bufferDuration is the time from current time that is buffered. + val playableDurationFromNow = max(0.0, bufferedDurationSeconds - currentTimeSeconds) + + eventEmitter.onProgress( + onProgressData( + currentTime = currentTimeSeconds, + bufferDuration = playableDurationFromNow + ) + ) + progressHandler.postDelayed(this, PROGRESS_UPDATE_INTERVAL_MS) + } + } + } + progressHandler.post(progressRunnable ?: return) + } + + private fun stopProgressUpdates() { + progressRunnable?.let { progressHandler.removeCallbacks(it) } + progressRunnable = null + } + + private val analyticsListener = object: AnalyticsListener { + override fun onBandwidthEstimate( + eventTime: AnalyticsListener.EventTime, + totalLoadTimeMs: Int, + totalBytesLoaded: Long, + bitrateEstimate: Long + ) { + val videoFormat = player.videoFormat + eventEmitter.onBandwidthUpdate( + BandwidthData( + bitrate = bitrateEstimate.toDouble(), + width = if (videoFormat != null) videoFormat.width.toDouble() else null, + height = if (videoFormat != null) videoFormat.height.toDouble() else null + ) + ) + } + } + + private val playerListener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + val isPlayingUpdate = player.isPlaying + val isBufferingUpdate = playbackState == Player.STATE_BUFFERING + + eventEmitter.onPlaybackStateChange( + onPlaybackStateChangeData( + isPlaying = isPlayingUpdate, + isBuffering = isBufferingUpdate + ) + ) + + when (playbackState) { + Player.STATE_IDLE -> { + status = VideoPlayerStatus.IDLE + eventEmitter.onBuffer(false) + } + Player.STATE_BUFFERING -> { + status = VideoPlayerStatus.LOADING + eventEmitter.onBuffer(true) + } + Player.STATE_READY -> { + status = VideoPlayerStatus.READYTOPLAY + eventEmitter.onBuffer(false) + + val generalVideoFormat = player.videoFormat + val currentTracks = player.currentTracks + + val selectedVideoTrackGroup = currentTracks.groups.find { group -> group.type == C.TRACK_TYPE_VIDEO && group.isSelected } + val selectedVideoTrackFormat = if (selectedVideoTrackGroup != null && selectedVideoTrackGroup.length > 0) { + selectedVideoTrackGroup.getTrackFormat(0) + } else { + null + } + + val width = selectedVideoTrackFormat?.width ?: generalVideoFormat?.width ?: 0 + val height = selectedVideoTrackFormat?.height ?: generalVideoFormat?.height ?: 0 + val rotationDegrees = selectedVideoTrackFormat?.rotationDegrees ?: generalVideoFormat?.rotationDegrees + + eventEmitter.onLoad( + onLoadData( + currentTime = player.currentPosition / 1000.0, + duration = if (player.duration == C.TIME_UNSET) Double.NaN else player.duration / 1000.0, + width = width.toDouble(), + height = height.toDouble(), + orientation = VideoOrientationUtils.fromWHR(width, height, rotationDegrees) + ) + ) + // If player becomes ready and is set to play, start progress updates + if (player.playWhenReady) { + startProgressUpdates() + } + + eventEmitter.onReadyToDisplay() + } + Player.STATE_ENDED -> { + status = VideoPlayerStatus.IDLE // Or a specific 'COMPLETED' status if you add one + eventEmitter.onEnd() + eventEmitter.onBuffer(false) + stopProgressUpdates() + } + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + eventEmitter.onPlaybackStateChange( + onPlaybackStateChangeData( + isPlaying = isPlaying, + isBuffering = player.playbackState == Player.STATE_BUFFERING + ) + ) + if (isPlaying) { + VideoManager.setLastPlayedPlayer(this@HybridVideoPlayer) + startProgressUpdates() + } else { + if (player.playbackState == Player.STATE_ENDED || player.playbackState == Player.STATE_IDLE) { + stopProgressUpdates() + } + } + } + + override fun onPlayerError(error: PlaybackException) { + status = VideoPlayerStatus.ERROR + stopProgressUpdates() + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + if (reason == Player.DISCONTINUITY_REASON_SEEK || reason == Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT) { + eventEmitter.onSeek(newPosition.positionMs / 1000.0) + } + // Update progress immediately after a discontinuity if needed by your logic + val currentTimeSeconds = newPosition.positionMs / 1000.0 + val bufferedDurationSeconds = player.bufferedPosition / 1000.0 + eventEmitter.onProgress( + onProgressData( + currentTime = currentTimeSeconds, + bufferDuration = max(0.0, bufferedDurationSeconds - currentTimeSeconds) + ) + ) + } + + override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { + eventEmitter.onPlaybackRateChange(playbackParameters.speed.toDouble()) + } + + override fun onVolumeChanged(volume: Float) { + // We get here device volume changes, and if + // player is not muted we will sync it + if (!muted) { + this@HybridVideoPlayer.volume = volume.toDouble() + } + + VideoManager.audioFocusManager.requestAudioFocusUpdate() + eventEmitter.onVolumeChange(onVolumeChangeData( + volume = volume.toDouble(), + muted = muted + )) + } + + override fun onCues(cueGroup: CueGroup) { + val texts = cueGroup.cues.mapNotNull { it.text?.toString() } + if (texts.isNotEmpty()) { + eventEmitter.onTextTrackDataChanged(texts.toTypedArray()) + } + } + + override fun onMetadata(metadata: Metadata) { + val timedMetadataObjects = mutableListOf() + for (i in 0 until metadata.length()) { + val entry = metadata.get(i) + + when (entry) { + is Id3Frame -> { + var value = "" + + if (entry is TextInformationFrame) { + value = entry.values.first() + } + + timedMetadataObjects.add(TimedMetadataObject(entry.id, value)) + } + is EventMessage -> + timedMetadataObjects.add(TimedMetadataObject(entry.schemeIdUri, entry.value)) + else -> Log.d(TAG, "Unknown metadata: $entry") + } + } + if (timedMetadataObjects.isNotEmpty()) { + eventEmitter.onTimedMetadata(TimedMetadata(metadata = timedMetadataObjects.toTypedArray())) + } + } + + override fun onTracksChanged(tracks: Tracks) { + super.onTracksChanged(tracks) + } + } + + // MARK: - Text Track Management + + override fun getAvailableTextTracks(): Array { + return TextTrackUtils.getAvailableTextTracks(player, source) + } + + override fun selectTextTrack(textTrack: Variant_NullType_TextTrack?) { + selectedExternalTrackIndex = TextTrackUtils.selectTextTrack( + player = player, + textTrack = textTrack?.asSecondOrNull(), + source = source, + onTrackChange = { track -> eventEmitter.onTrackChange(track) } + ) + } + + override val selectedTrack: TextTrack? + get() = TextTrackUtils.getSelectedTrack(player, source) +} diff --git a/android/src/main/java/com/twg/video/hybrids/videoplayer/HybridVideoPlayerFactory.kt b/android/src/main/java/com/twg/video/hybrids/videoplayer/HybridVideoPlayerFactory.kt new file mode 100644 index 00000000..edf00a06 --- /dev/null +++ b/android/src/main/java/com/twg/video/hybrids/videoplayer/HybridVideoPlayerFactory.kt @@ -0,0 +1,16 @@ +package com.margelo.nitro.video + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import com.facebook.proguard.annotations.DoNotStrip + +@DoNotStrip +class HybridVideoPlayerFactory(): HybridVideoPlayerFactorySpec() { + @OptIn(UnstableApi::class) + override fun createPlayer(source: HybridVideoPlayerSourceSpec): HybridVideoPlayerSpec { + return HybridVideoPlayer(source as HybridVideoPlayerSource) + } + + override val memorySize: Long + get() = 0 +} diff --git a/android/src/main/java/com/twg/video/hybrids/videoplayereventemitter/HybridVideoPlayerEventEmitter.kt b/android/src/main/java/com/twg/video/hybrids/videoplayereventemitter/HybridVideoPlayerEventEmitter.kt new file mode 100644 index 00000000..ecdcbf6a --- /dev/null +++ b/android/src/main/java/com/twg/video/hybrids/videoplayereventemitter/HybridVideoPlayerEventEmitter.kt @@ -0,0 +1,180 @@ +package com.margelo.nitro.video + +import android.util.Log +import com.margelo.nitro.core.NullType +import java.util.UUID + +data class ListenerPair(val id: UUID, val eventName: String, val callback: Any) + +class HybridVideoPlayerEventEmitter : HybridVideoPlayerEventEmitterSpec() { + private val lock = Any() + + var listeners: MutableList = mutableListOf() + + // MARK: - Private helpers + private fun addListener(eventName: String, listener: T): ListenerSubscription { + val id = UUID.randomUUID() + synchronized(lock) { + listeners.add(ListenerPair(id, eventName, listener)) + } + return ListenerSubscription { + synchronized(lock) { + listeners.removeAll { it.id == id } + } + } + } + + private inline fun emitEvent(eventName: String, invokeCallback: (T) -> Unit) { + val snapshot: List = synchronized(lock) { + listeners.filter { it.eventName == eventName }.toList() + } + + snapshot.forEach { pair -> + try { + @Suppress("UNCHECKED_CAST") + val callback = pair.callback as? T ?: run { + Log.d(TAG, "Invalid callback type for $eventName") + return@forEach + } + invokeCallback(callback) + } catch (t: Throwable) { + Log.d(TAG, "Error calling $eventName listener", t) + } + } + } + + + // MARK: - Listener registration methods + + override fun addOnAudioBecomingNoisyListener(listener: () -> Unit) = + addListener("onAudioBecomingNoisy", listener) + + override fun addOnAudioFocusChangeListener(listener: (Boolean) -> Unit) = + addListener("onAudioFocusChange", listener) + + override fun addOnBandwidthUpdateListener(listener: (BandwidthData) -> Unit) = + addListener("onBandwidthUpdate", listener) + + override fun addOnBufferListener(listener: (Boolean) -> Unit) = + addListener("onBuffer", listener) + + override fun addOnControlsVisibleChangeListener(listener: (Boolean) -> Unit) = + addListener("onControlsVisibleChange", listener) + + override fun addOnEndListener(listener: () -> Unit) = + addListener("onEnd", listener) + + override fun addOnExternalPlaybackChangeListener(listener: (Boolean) -> Unit) = + addListener("onExternalPlaybackChange", listener) + + override fun addOnLoadListener(listener: (onLoadData) -> Unit) = + addListener("onLoad", listener) + + override fun addOnLoadStartListener(listener: (onLoadStartData) -> Unit) = + addListener("onLoadStart", listener) + + override fun addOnPlaybackStateChangeListener(listener: (onPlaybackStateChangeData) -> Unit) = + addListener("onPlaybackStateChange", listener) + + override fun addOnPlaybackRateChangeListener(listener: (Double) -> Unit) = + addListener("onPlaybackRateChange", listener) + + override fun addOnProgressListener(listener: (onProgressData) -> Unit) = + addListener("onProgress", listener) + + override fun addOnReadyToDisplayListener(listener: () -> Unit) = + addListener("onReadyToDisplay", listener) + + override fun addOnSeekListener(listener: (Double) -> Unit) = + addListener("onSeek", listener) + + override fun addOnStatusChangeListener(listener: (VideoPlayerStatus) -> Unit) = + addListener("onStatusChange", listener) + + override fun addOnTimedMetadataListener(listener: (TimedMetadata) -> Unit) = + addListener("onTimedMetadata", listener) + + override fun addOnTextTrackDataChangedListener(listener: (Array) -> Unit) = + addListener("onTextTrackDataChanged", listener) + + override fun addOnTrackChangeListener(listener: (Variant_NullType_TextTrack?) -> Unit) = + addListener("onTrackChange", listener) + + override fun addOnVolumeChangeListener(listener: (onVolumeChangeData) -> Unit) = + addListener("onVolumeChange", listener) + + override fun clearAllListeners() { + synchronized(lock) { + listeners.clear() + } + } + + // MARK: - Event emission methods + + fun onAudioBecomingNoisy() = + emitEvent<() -> Unit>("onAudioBecomingNoisy") { it() } + + fun onAudioFocusChange(hasFocus: Boolean) = + emitEvent<(Boolean) -> Unit>("onAudioFocusChange") { it(hasFocus) } + + fun onBandwidthUpdate(data: BandwidthData) = + emitEvent<(BandwidthData) -> Unit>("onBandwidthUpdate") { it(data) } + + fun onBuffer(isBuffering: Boolean) = + emitEvent<(Boolean) -> Unit>("onBuffer") { it(isBuffering) } + + fun onControlsVisibleChange(isVisible: Boolean) = + emitEvent<(Boolean) -> Unit>("onControlsVisibleChange") { it(isVisible) } + + fun onEnd() = + emitEvent<() -> Unit>("onEnd") { it() } + + fun onExternalPlaybackChange(isExternalPlayback: Boolean) = + emitEvent<(Boolean) -> Unit>("onExternalPlaybackChange") { it(isExternalPlayback) } + + fun onLoad(data: onLoadData) = + emitEvent<(onLoadData) -> Unit>("onLoad") { it(data) } + + fun onLoadStart(data: onLoadStartData) = + emitEvent<(onLoadStartData) -> Unit>("onLoadStart") { it(data) } + + fun onPlaybackStateChange(data: onPlaybackStateChangeData) = + emitEvent<(onPlaybackStateChangeData) -> Unit>("onPlaybackStateChange") { it(data) } + + fun onPlaybackRateChange(rate: Double) = + emitEvent<(Double) -> Unit>("onPlaybackRateChange") { it(rate) } + + fun onProgress(data: onProgressData) = + emitEvent<(onProgressData) -> Unit>("onProgress") { it(data) } + + fun onReadyToDisplay() = + emitEvent<() -> Unit>("onReadyToDisplay") { it() } + + fun onSeek(position: Double) = + emitEvent<(Double) -> Unit>("onSeek") { it(position) } + + fun onTimedMetadata(metadata: TimedMetadata) = + emitEvent<(TimedMetadata) -> Unit>("onTimedMetadata") { it(metadata) } + + fun onTextTrackDataChanged(tracks: Array) = + emitEvent<(Array) -> Unit>("onTextTrackDataChanged") { it(tracks) } + + fun onTrackChange(track: TextTrack?) { + val param = if (track == null) { + Variant_NullType_TextTrack.create(NullType.NULL) + } else { + Variant_NullType_TextTrack.create(track) + } + emitEvent<(Variant_NullType_TextTrack?) -> Unit>("onTrackChange") { it(param) } + } + + fun onVolumeChange(data: onVolumeChangeData) = + emitEvent<(onVolumeChangeData) -> Unit>("onVolumeChange") { it(data) } + + fun onStatusChange(status: VideoPlayerStatus) = + emitEvent<(VideoPlayerStatus) -> Unit>("onStatusChange") { it(status) } + + companion object { + const val TAG = "HybridVideoPlayerEventEmitter" + } +} diff --git a/android/src/main/java/com/twg/video/hybrids/videoplayersource/HybridVideoPlayerSource.kt b/android/src/main/java/com/twg/video/hybrids/videoplayersource/HybridVideoPlayerSource.kt new file mode 100644 index 00000000..1cb2ed1b --- /dev/null +++ b/android/src/main/java/com/twg/video/hybrids/videoplayersource/HybridVideoPlayerSource.kt @@ -0,0 +1,67 @@ +package com.margelo.nitro.video + +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.drm.DrmSessionManager +import androidx.media3.exoplayer.source.MediaSource +import com.margelo.nitro.NitroModules +import com.margelo.nitro.core.Promise +import com.twg.video.core.LibraryError +import com.twg.video.core.player.DRMManagerSpec +import com.twg.video.core.player.buildMediaSource +import com.twg.video.core.player.createMediaItemFromVideoConfig +import com.twg.video.core.plugins.PluginsRegistry +import com.twg.video.core.utils.SourceLoader +import com.twg.video.core.utils.VideoInformationUtils + +class HybridVideoPlayerSource(): HybridVideoPlayerSourceSpec() { + override lateinit var uri: String + override lateinit var config: NativeVideoConfig + + private lateinit var mediaItem: MediaItem + lateinit var mediaSource: MediaSource + + var drmManager: DRMManagerSpec? = null + + @UnstableApi + var drmSessionManager: DrmSessionManager? = null + + internal val sourceLoader = SourceLoader() + + constructor(config: NativeVideoConfig) : this() { + this.uri = config.uri + this.config = config + + val overriddenSource = PluginsRegistry.shared.overrideSource(this) + + config.drm?.let { + drmManager = PluginsRegistry.shared.getDRMManager(this) + drmSessionManager = drmManager?.buildDrmSessionManager(it) + } + + this.mediaItem = createMediaItemFromVideoConfig( + overriddenSource + ) + + NitroModules.applicationContext?.let { + this.mediaSource = buildMediaSource( + context = it, + source = overriddenSource, + mediaItem + ) + } ?: run { + throw LibraryError.ApplicationContextNotFound + } + } + + override fun getAssetInformationAsync(): Promise { + return Promise.async { + return@async sourceLoader.load { + VideoInformationUtils.fromUri(uri, config.headers ?: emptyMap()) + } + } + } + + override val memorySize: Long + get() = 0 +} diff --git a/android/src/main/java/com/twg/video/hybrids/videoplayersource/HybridVideoPlayerSourceFactory.kt b/android/src/main/java/com/twg/video/hybrids/videoplayersource/HybridVideoPlayerSourceFactory.kt new file mode 100644 index 00000000..fb4730f6 --- /dev/null +++ b/android/src/main/java/com/twg/video/hybrids/videoplayersource/HybridVideoPlayerSourceFactory.kt @@ -0,0 +1,58 @@ +package com.margelo.nitro.video + +import android.content.Context +import android.net.Uri +import androidx.media3.common.MediaItem +import androidx.media3.datasource.RawResourceDataSource +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.proguard.annotations.DoNotStrip +import com.margelo.nitro.NitroModules + +@DoNotStrip +class HybridVideoPlayerSourceFactory: HybridVideoPlayerSourceFactorySpec() { + private val applicationContext: Context + get() = NitroModules.applicationContext + ?: throw IllegalStateException( + "NitroModules.applicationContext has not been initialized." + ) + + private fun normalizeUri(input: String): String { + val parsedUri = Uri.parse(input) + + if (parsedUri.scheme != null) { + return parsedUri.toString() + } + + val resId = applicationContext.resources + .getIdentifier(input, "raw", applicationContext.packageName) + + if (resId == 0) { + throw IllegalArgumentException("The video resource '$input' could not be found in res/raw") + } + + val mediaUri = RawResourceDataSource.buildRawResourceUri(resId) + + return mediaUri.toString() + } + + override fun fromUri(uri: String): HybridVideoPlayerSourceSpec { + val config = NativeVideoConfig( + uri = normalizeUri(uri), + externalSubtitles = null, + drm = null, + headers = null, + bufferConfig = null, + metadata = null, + initializeOnCreation = true + ) + + return HybridVideoPlayerSource(config) + } + + override fun fromVideoConfig(config: NativeVideoConfig): HybridVideoPlayerSourceSpec { + return HybridVideoPlayerSource(config) + } + + override val memorySize: Long + get() = 0 +} diff --git a/android/src/main/java/com/twg/video/hybrids/videoviewviewmanager/HybridVideoViewViewManager.kt b/android/src/main/java/com/twg/video/hybrids/videoviewviewmanager/HybridVideoViewViewManager.kt new file mode 100644 index 00000000..e6432651 --- /dev/null +++ b/android/src/main/java/com/twg/video/hybrids/videoviewviewmanager/HybridVideoViewViewManager.kt @@ -0,0 +1,187 @@ +package com.margelo.nitro.video + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import com.facebook.proguard.annotations.DoNotStrip +import com.twg.video.core.VideoManager +import com.twg.video.core.VideoViewError +import com.twg.video.core.utils.PictureInPictureUtils +import com.twg.video.core.utils.Threading +import java.util.UUID + +data class ViewListenerPair( + val id: UUID, + val eventName: String, + val callback: Any +) + +@DoNotStrip +@OptIn(UnstableApi::class) +class HybridVideoViewViewManager(nitroId: Int): HybridVideoViewViewManagerSpec(), VideoViewEventsEmitter { + private var videoView = + VideoManager.getVideoViewWeakReferenceByNitroId(nitroId) ?: throw VideoViewError.ViewNotFound(nitroId) + private val listeners = mutableListOf() + + init { + videoView.get()?.eventsEmitter = this + } + + override var player: HybridVideoPlayerSpec? + get() { + return Threading.runOnMainThreadSync { return@runOnMainThreadSync videoView.get()?.hybridPlayer } + } + set(value) { + Threading.runOnMainThread { + videoView.get()?.hybridPlayer = value as? HybridVideoPlayer + } + } + + override fun canEnterPictureInPicture(): Boolean { + return PictureInPictureUtils.canEnterPictureInPicture() + } + + override fun enterFullscreen() { + videoView.get()?.enterFullscreen() + } + + override fun exitFullscreen() { + videoView.get()?.exitFullscreen() + } + + override fun enterPictureInPicture() { + Threading.runOnMainThread { + videoView.get()?.enterPictureInPicture() + } + } + + override fun exitPictureInPicture() { + Threading.runOnMainThread { + videoView.get()?.exitPictureInPicture() + } + } + + override var autoEnterPictureInPicture: Boolean + get() = videoView.get()?.autoEnterPictureInPicture == true + set(value) { + Threading.runOnMainThread { + videoView.get()?.autoEnterPictureInPicture = value + } + } + + override var pictureInPicture: Boolean + get() = videoView.get()?.pictureInPictureEnabled == true + set(value) { + videoView.get()?.pictureInPictureEnabled = value + } + + override var controls: Boolean + get() = videoView.get()?.useController == true + set(value) { + videoView.get()?.useController = value + } + + override var resizeMode: ResizeMode + get() = videoView.get()?.resizeMode ?: ResizeMode.NONE + set(value) { + videoView.get()?.resizeMode = value + } + + override var keepScreenAwake: Boolean + get() = videoView.get()?.keepScreenAwake == true + set(value) { + videoView.get()?.keepScreenAwake = value + } + + override var surfaceType: SurfaceType + get() = videoView.get()?.surfaceType ?: SurfaceType.SURFACE + set(value) { + videoView.get()?.surfaceType = value + } + + // MARK: - Private helpers + + private fun addListener(eventName: String, listener: T): ListenerSubscription { + val id = UUID.randomUUID() + listeners.add(ViewListenerPair(id, eventName, listener as Any)) + return ListenerSubscription { listeners.removeAll { it.id == id } } + } + + @Suppress("UNCHECKED_CAST") + private fun emitEvent(eventName: String, invoke: (T) -> Unit) { + listeners.filter { it.eventName == eventName }.forEach { pair -> + try { + invoke(pair.callback as T) + } catch (e: Exception) { + println("[ReactNativeVideo] Error calling $eventName listener: $e") + } + } + } + + // MARK: - Listener registration methods + + override fun addOnPictureInPictureChangeListener(listener: (Boolean) -> Unit): ListenerSubscription { + return addListener("onPictureInPictureChange", listener) + } + + override fun addOnFullscreenChangeListener(listener: (Boolean) -> Unit): ListenerSubscription { + return addListener("onFullscreenChange", listener) + } + + override fun addWillEnterFullscreenListener(listener: () -> Unit): ListenerSubscription { + return addListener("willEnterFullscreen", listener) + } + + override fun addWillExitFullscreenListener(listener: () -> Unit): ListenerSubscription { + return addListener("willExitFullscreen", listener) + } + + override fun addWillEnterPictureInPictureListener(listener: () -> Unit): ListenerSubscription { + return addListener("willEnterPictureInPicture", listener) + } + + override fun addWillExitPictureInPictureListener(listener: () -> Unit): ListenerSubscription { + return addListener("willExitPictureInPicture", listener) + } + + override fun clearAllListeners() { + listeners.clear() + } + + // MARK: - Event emission methods (called by VideoView) + + override fun onPictureInPictureChange(isActive: Boolean) { + emitEvent<(Boolean) -> Unit>("onPictureInPictureChange") { it(isActive) } + } + + override fun onFullscreenChange(isActive: Boolean) { + emitEvent<(Boolean) -> Unit>("onFullscreenChange") { it(isActive) } + } + + override fun willEnterFullscreen() { + emitEvent<() -> Unit>("willEnterFullscreen") { it() } + } + + override fun willExitFullscreen() { + emitEvent<() -> Unit>("willExitFullscreen") { it() } + } + + override fun willEnterPictureInPicture() { + emitEvent<() -> Unit>("willEnterPictureInPicture") { it() } + } + + override fun willExitPictureInPicture() { + emitEvent<() -> Unit>("willExitPictureInPicture") { it() } + } + + override val memorySize: Long + get() = 0 +} + +interface VideoViewEventsEmitter { + fun onPictureInPictureChange(isActive: Boolean) + fun onFullscreenChange(isActive: Boolean) + fun willEnterFullscreen() + fun willExitFullscreen() + fun willEnterPictureInPicture() + fun willExitPictureInPicture() +} diff --git a/android/src/main/java/com/twg/video/hybrids/videoviewviewmanager/HybridVideoViewViewManagerFactory.kt b/android/src/main/java/com/twg/video/hybrids/videoviewviewmanager/HybridVideoViewViewManagerFactory.kt new file mode 100644 index 00000000..00865e63 --- /dev/null +++ b/android/src/main/java/com/twg/video/hybrids/videoviewviewmanager/HybridVideoViewViewManagerFactory.kt @@ -0,0 +1,13 @@ +package com.margelo.nitro.video + +import com.facebook.proguard.annotations.DoNotStrip + +@DoNotStrip +class HybridVideoViewViewManagerFactory: HybridVideoViewViewManagerFactorySpec() { + override fun createViewManager(nitroId: Double): HybridVideoViewViewManagerSpec { + return HybridVideoViewViewManager(nitroId.toInt()) + } + + override val memorySize: Long + get() = 0 +} diff --git a/android/src/main/java/com/twg/video/react/VideoPackage.kt b/android/src/main/java/com/twg/video/react/VideoPackage.kt new file mode 100644 index 00000000..9960261e --- /dev/null +++ b/android/src/main/java/com/twg/video/react/VideoPackage.kt @@ -0,0 +1,25 @@ +package com.twg.video.react + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class VideoPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return emptyList() + } + + @OptIn(UnstableApi::class) + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return listOf(VideoViewViewManager()) + } + + companion object { + init { + System.loadLibrary("ReactNativeVideo") + } + } +} diff --git a/android/src/main/java/com/twg/video/react/VideoViewViewManager.kt b/android/src/main/java/com/twg/video/react/VideoViewViewManager.kt new file mode 100644 index 00000000..ed47dfb3 --- /dev/null +++ b/android/src/main/java/com/twg/video/react/VideoViewViewManager.kt @@ -0,0 +1,78 @@ +package com.twg.video.react + +import androidx.media3.common.util.UnstableApi +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.common.MapBuilder +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.events.Event +import com.facebook.react.viewmanagers.RNCVideoViewManagerDelegate +import com.facebook.react.viewmanagers.RNCVideoViewManagerInterface +import com.twg.video.view.VideoView + +internal class NitroIdChange( + surfaceId: Int, + viewTag: Int, + val nitroId: Int +) : Event(surfaceId, viewTag) { + override fun getEventName() = EVENT_NAME + + override fun getEventData(): WritableMap = Arguments.createMap().apply { + putInt("nitroId", nitroId) + } + + companion object { + const val EVENT_NAME = "topNitroIdChange" + } +} + +@UnstableApi +@ReactModule(name = VideoViewViewManager.NAME) +class VideoViewViewManager : SimpleViewManager(), RNCVideoViewManagerInterface { + private val mDelegate: ViewManagerDelegate + + init { + mDelegate = RNCVideoViewManagerDelegate(this) + } + + @ReactProp(name = "nitroId") + override fun setNitroId(view: VideoView, nitroId: Int) { + view.nitroId = nitroId + } + + public override fun createViewInstance(reactContext: ThemedReactContext): VideoView { + return VideoView(reactContext) + } + + override fun getExportedCustomDirectEventTypeConstants(): Map { + return MapBuilder.builder() + .put(NitroIdChange.EVENT_NAME, MapBuilder.of("registrationName", "onNitroIdChange")) + .build() + } + + override fun getName() = NAME + + override fun getDelegate() = mDelegate + + override fun addEventEmitters(reactContext: ThemedReactContext, view: VideoView) { + super.addEventEmitters(reactContext, view) + + val surfaceId = UIManagerHelper.getSurfaceId(reactContext) + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id) + + if (dispatcher != null) { + view.onNitroIdChange = { + dispatcher.dispatchEvent(NitroIdChange(surfaceId, view.id, view.nitroId)) + } + } + } + + companion object { + const val NAME = "RNCVideoView" + } +} diff --git a/android/src/main/java/com/twg/video/view/VideoView.kt b/android/src/main/java/com/twg/video/view/VideoView.kt new file mode 100644 index 00000000..121c7bd9 --- /dev/null +++ b/android/src/main/java/com/twg/video/view/VideoView.kt @@ -0,0 +1,565 @@ +package com.twg.video.view + +import android.annotation.SuppressLint +import android.app.PictureInPictureParams +import android.content.Context +import android.graphics.Color +import android.os.Build +import android.util.AttributeSet +import android.util.DisplayMetrics +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.ImageButton +import androidx.annotation.RequiresApi +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentActivity +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.PlayerView +import com.facebook.react.bridge.ReactApplicationContext +import com.margelo.nitro.NitroModules +import com.margelo.nitro.video.HybridVideoPlayer +import com.margelo.nitro.video.ResizeMode +import com.margelo.nitro.video.SurfaceType +import com.margelo.nitro.video.VideoViewEventsEmitter +import com.twg.video.core.LibraryError +import com.twg.video.core.VideoManager +import com.twg.video.core.VideoViewError +import com.twg.video.core.fragments.FullscreenVideoFragment +import com.twg.video.core.fragments.PictureInPictureHelperFragment +import com.twg.video.core.utils.PictureInPictureUtils.canEnterPictureInPicture +import com.twg.video.core.utils.PictureInPictureUtils.createPictureInPictureParams +import com.twg.video.core.utils.PictureInPictureUtils.safeEnterPictureInPictureMode +import com.twg.video.core.utils.Threading.runOnMainThread +import com.twg.video.core.extensions.toAspectRatioFrameLayout +import com.twg.video.core.utils.PictureInPictureUtils +import com.twg.video.core.utils.PictureInPictureUtils.createDisabledPictureInPictureParams +import com.twg.video.core.utils.SmallVideoPlayerOptimizer +import com.twg.video.R.layout.player_view_surface +import com.twg.video.R.layout.player_view_texture + +@UnstableApi +class VideoView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + var hybridPlayer: HybridVideoPlayer? = null + set(value) { + // Clear the SurfaceView when player is about to be set to null + if (value == null && field != null) { + VideoManager.removeViewFromPlayer(this, field!!) + } + + field = value + + field?.movePlayerToVideoView(this) + } + + var nitroId: Int = -1 + set(value) { + if (field == -1) { + post { + onNitroIdChange?.let { it(value) } + VideoManager.registerView(this) + } + } + + VideoManager.updateVideoViewNitroId(oldNitroId = field, newNitroId = value, view = this) + field = value + } + + var autoEnterPictureInPicture: Boolean = false + set(value) { + field = value + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + PictureInPictureUtils.safeSetPictureInPictureParams( + if (value) createPictureInPictureParams(this) + else createDisabledPictureInPictureParams(this) + ) + } + } + + var useController: Boolean = false + set(value) { + field = value + runOnMainThread { + playerView.useController = value + } + } + + var pictureInPictureEnabled: Boolean = false + + var surfaceType: SurfaceType = SurfaceType.SURFACE + set(value) { + if (field == value) return + field = value + + runOnMainThread { + removeView(playerView) + playerView.player = null + playerView = createPlayerView() + playerView.player = hybridPlayer?.player + addView(playerView) + } + } + + var resizeMode: ResizeMode = ResizeMode.NONE + set(value) { + field = value + runOnMainThread { + applyResizeMode() + } + } + + var keepScreenAwake: Boolean + get() = playerView.keepScreenOn + set(value) { + runOnMainThread { + playerView.keepScreenOn = value + } + } + + var eventsEmitter: VideoViewEventsEmitter? = null + + var onNitroIdChange: ((Int?) -> Unit)? = null + var playerView = createPlayerView() + var isInFullscreen: Boolean = false + set(value) { + if (value != field) { + eventsEmitter?.onFullscreenChange(value) + } + field = value + } + var isInPictureInPicture: Boolean = false + set(value) { + field = value + + if (value) { + playerView.useController = false + playerView.controllerAutoShow = false + playerView.controllerHideOnTouch = true + } else { + playerView.useController = useController + playerView.controllerAutoShow = true + playerView.controllerHideOnTouch = true + } + + eventsEmitter?.onPictureInPictureChange(value) + } + private var rootContentViews: List = listOf() + private var pictureInPictureHelperTag: String? = null + private var fullscreenFragmentTag: String? = null + private var movedToRootForPiP: Boolean = false + + val applicationContent: ReactApplicationContext + get() { + return NitroModules.applicationContext ?: throw LibraryError.ApplicationContextNotFound + } + + init { + addView(playerView) + setupFullscreenButton() + applyResizeMode() + } + + private fun applyResizeMode() { + playerView.resizeMode = resizeMode.toAspectRatioFrameLayout() + } + + @SuppressLint("InflateParams") + private fun createPlayerView(): PlayerView { + return when (surfaceType) { + SurfaceType.SURFACE -> LayoutInflater.from(context).inflate(player_view_surface, null) as PlayerView + SurfaceType.TEXTURE -> LayoutInflater.from(context).inflate(player_view_texture, null) as PlayerView + }.apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + setShutterBackgroundColor(Color.TRANSPARENT) + setShowSubtitleButton(true) + useController = false + + // Apply optimizations based on video player size if needed + configureForSmallPlayer() + } + } + + private val layoutRunnable = Runnable { + measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) + ) + layout(left, top, right, bottom) + + // Additional layout fixes for small video players + applySmallPlayerLayoutFixes() + } + + override fun requestLayout() { + super.requestLayout() + + // https://github.com/facebook/react-native/blob/d19afc73f5048f81656d0b4424232ce6d69a6368/ReactAndroid/src/main/java/com/facebook/react/views/toolbar/ReactToolbar.java#L166 + // This fix issue where exoplayer views where wrong sizes + // Without it, controls, PictureInPicture, content fills, etc. don't work + post(layoutRunnable) + } + + @SuppressLint("PrivateResource") + private fun setupFullscreenButton() { + playerView.setFullscreenButtonClickListener { _ -> + enterFullscreen() + } + + playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen) + ?.setImageResource(androidx.media3.ui.R.drawable.exo_ic_fullscreen_enter) + } + + fun enterFullscreen() { + if (isInFullscreen) { + return + } + + val currentActivity = applicationContent.currentActivity + if (currentActivity !is FragmentActivity) { + Log.e("ReactNativeVideo", "Current activity is not a FragmentActivity, cannot enter fullscreen") + return + } + + try { + eventsEmitter?.willEnterFullscreen() + + val fragment = FullscreenVideoFragment(this) + fullscreenFragmentTag = fragment.id + + currentActivity.supportFragmentManager.beginTransaction() + .add(fragment, fragment.id) + .commitAllowingStateLoss() + + isInFullscreen = true + } catch (err: Exception) { + val debugMessage = "Failed to start fullscreen fragment for nitroId: $nitroId" + Log.e("ReactNativeVideo", debugMessage, err) + } + } + + @SuppressLint("PrivateResource") + fun exitFullscreen() { + if (!isInFullscreen) { + return + } + + eventsEmitter?.willExitFullscreen() + + val currentActivity = applicationContent.currentActivity + fullscreenFragmentTag?.let { tag -> + (currentActivity as? FragmentActivity)?.let { activity -> + activity.supportFragmentManager.findFragmentByTag(tag)?.let { fragment -> + // The fragment will handle its own removal in exitFullscreen() + if (fragment is FullscreenVideoFragment) { + runOnMainThread { + fragment.exitFullscreen() + } + } + } + } + fullscreenFragmentTag = null + } + + // Change fullscreen button icon back to enter fullscreen and update callback + setupFullscreenButton() + + isInFullscreen = false + } + + private fun setupPipHelper() { + if (!canEnterPictureInPicture()) { + return + } + + val currentActivity = applicationContent.currentActivity + (currentActivity as? FragmentActivity)?.let { + val fragment = PictureInPictureHelperFragment(this) + pictureInPictureHelperTag = fragment.id + it.supportFragmentManager.beginTransaction() + .add(fragment, fragment.id) + .commitAllowingStateLoss() + } + } + + private fun removePipHelper() { + val currentActivity = applicationContent.currentActivity + pictureInPictureHelperTag?.let { tag -> + (currentActivity as? FragmentActivity)?.let { activity -> + activity.supportFragmentManager.findFragmentByTag(tag)?.let { fragment -> + activity.supportFragmentManager.beginTransaction() + .remove(fragment) + .commitAllowingStateLoss() + } + } + pictureInPictureHelperTag = null + } + } + + private fun removeFullscreenFragment() { + val currentActivity = applicationContent.currentActivity + fullscreenFragmentTag?.let { tag -> + (currentActivity as? FragmentActivity)?.let { activity -> + activity.supportFragmentManager.findFragmentByTag(tag)?.let { fragment -> + activity.supportFragmentManager.beginTransaction() + .remove(fragment) + .commitAllowingStateLoss() + } + } + fullscreenFragmentTag = null + } + } + + fun hideRootContentViews() { + // Safety check: Only proceed if this is the designated PiP video + if (VideoManager.getCurrentPictureInPictureVideo() != this) { + Log.w("ReactNativeVideo", "hideRootContentViews called on non-PiP video nitroId: $nitroId - ignoring") + return + } + + Log.d("ReactNativeVideo", "Hiding root content views for PiP video nitroId: $nitroId") + + // In PiP mode, disable controls immediately - media session creates its own controls for PiP + playerView.useController = false + + playerView.setBackgroundColor(Color.BLACK) + playerView.setShutterBackgroundColor(Color.BLACK) + + val currentActivity = applicationContent.currentActivity ?: return + val rootContent = currentActivity.window.decorView.findViewById(android.R.id.content) + + val fullscreenContainer = fullscreenFragmentTag?.let { tag -> + (currentActivity as? FragmentActivity)?.supportFragmentManager?.findFragmentByTag(tag) + ?.view + } + + rootContentViews = (0 until rootContent.childCount) + .map { rootContent.getChildAt(it) } + .filter { + it.isVisible && + it != playerView && + it != fullscreenContainer + } + + // If we're already in fullscreen, the PlayerView is inside the fullscreen container + // and root content is already hidden. Avoid moving the PlayerView again. + if (isInFullscreen) { + Log.d("ReactNativeVideo", "PiP entered while in fullscreen - skipping reparent to root for nitroId: $nitroId") + // Still hide the views we captured, but don't move the player view + rootContentViews.forEach { view -> view.visibility = GONE } + movedToRootForPiP = false + return + } + + (playerView.parent as? ViewGroup)?.removeView(playerView) + + rootContentViews.forEach { view -> view.visibility = GONE } + + rootContent.addView( + playerView, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + ) + movedToRootForPiP = true + + Log.d("ReactNativeVideo", "Successfully moved player view to root content for PiP nitroId: $nitroId") + } + + fun restoreRootContentViews() { + Log.d("ReactNativeVideo", "Restoring root content views for video nitroId: $nitroId") + + // Reset PlayerView settings + playerView.useController = useController + + playerView.setBackgroundColor(Color.BLACK) + playerView.setShutterBackgroundColor(Color.BLACK) + + if (movedToRootForPiP) { + (playerView.parent as? ViewGroup)?.removeView(playerView) + + val currentActivity = applicationContent.currentActivity ?: return + val rootContent = currentActivity.window.decorView.findViewById(android.R.id.content) + + rootContent.removeView(playerView) + + // Restore root content views + rootContentViews.forEach { it.visibility = View.VISIBLE } + rootContentViews = listOf() + movedToRootForPiP = false + } else { + // Even if we didn't move the player view to root, we might need to restore hidden views + // This can happen when entering PiP from fullscreen - the fullscreen fragment might have + // hidden views but we didn't track them in movedToRootForPiP + val currentActivity = applicationContent.currentActivity + if (currentActivity != null && rootContentViews.isNotEmpty()) { + Log.d("ReactNativeVideo", "Restoring root content views that were hidden (not moved to root) for nitroId: $nitroId") + rootContentViews.forEach { it.visibility = View.VISIBLE } + rootContentViews = listOf() + } + } + + // Add PlayerView back to this VideoView only if not already attached + if (playerView.parent != this) { + addView(playerView) + } + + Log.d("ReactNativeVideo", "Successfully restored root content views for video nitroId: $nitroId") + } + + fun enterPictureInPicture() { + if (isInPictureInPicture || isInFullscreen || !pictureInPictureEnabled) { + return + } + + if (!canEnterPictureInPicture()) { + throw VideoViewError.PictureInPictureNotSupported + } + + // Use centralized PiP manager to handle multiple videos + VideoManager.requestPictureInPicture(this) + } + + internal fun internalEnterPictureInPicture(): Boolean { + if (isInPictureInPicture || isInFullscreen || !pictureInPictureEnabled) { + return false + } + + if (!canEnterPictureInPicture()) { + return false + } + + val currentActivity = applicationContent.currentActivity ?: return false + + return try { + eventsEmitter?.willEnterPictureInPicture() + + // Disable controls before entering PiP - media session creates its own controls for PiP + runOnMainThread { + playerView.useController = false + playerView.controllerAutoShow = false + } + + val success = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val params = createPictureInPictureParams(this) + safeEnterPictureInPictureMode(params) + } else { + try { + @Suppress("Deprecation") + currentActivity.enterPictureInPictureMode() + true + } catch (e: Exception) { + Log.e("ReactNativeVideo", "Failed to enter PiP mode (legacy)", e) + false + } + } + + if (success) { + Log.d("ReactNativeVideo", "Successfully requested PiP mode for nitroId: $nitroId") + // Note: isInPictureInPicture will be set to true by the system callback in PictureInPictureHelperFragment + } else { + Log.w("ReactNativeVideo", "Failed to enter PiP mode for nitroId: $nitroId") + } + + success + } catch (e: Exception) { + Log.e("ReactNativeVideo", "Exception in internalEnterPictureInPicture for nitroId: $nitroId", e) + false + } + } + + fun exitPictureInPicture() { + if (!isInPictureInPicture) return + + VideoManager.notifyPictureInPictureExited(this) + + eventsEmitter?.willExitPictureInPicture() + + // Restore controls when exiting PiP - they were disabled because media session handles PiP controls + playerView.useController = useController + playerView.controllerAutoShow = true + + if (movedToRootForPiP) { + restoreRootContentViews() + } else { + Log.d("ReactNativeVideo", "Exiting PiP while in fullscreen - no reparent needed for nitroId: $nitroId") + } + + isInPictureInPicture = false + } + + internal fun forceExitPictureInPicture() { + if (!isInPictureInPicture) { + Log.d("ReactNativeVideo", "Force exit PiP skipped for nitroId: $nitroId (not in PiP)") + return + } + + Log.d("ReactNativeVideo", "Force exiting PiP for nitroId: $nitroId") + + try { + val currentActivity = applicationContent.currentActivity + if (currentActivity?.isInPictureInPictureMode == true) { + Log.d("ReactNativeVideo", "Activity is in PiP mode, preparing for transition") + } + + eventsEmitter?.willExitPictureInPicture() + + // Restore controls when exiting PiP - they were disabled because media session handles PiP controls + playerView.useController = useController + playerView.controllerAutoShow = true + + if (movedToRootForPiP) { + restoreRootContentViews() + } else { + Log.d("ReactNativeVideo", "Force exit PiP while in fullscreen - no reparent needed for nitroId: $nitroId") + } + isInPictureInPicture = false + + VideoManager.notifyPictureInPictureExited(this) + + Log.d("ReactNativeVideo", "Successfully force exited PiP for nitroId: $nitroId") + + } catch (e: Exception) { + Log.e("ReactNativeVideo", "Failed to force exit PiP mode for nitroId: $nitroId", e) + } + } + + // -------- View Lifecycle Methods -------- + override fun onDetachedFromWindow() { + removePipHelper() + removeFullscreenFragment() + VideoManager.unregisterView(this) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + PictureInPictureUtils.safeSetPictureInPictureParams( + createDisabledPictureInPictureParams(this) + ) + } + + super.onDetachedFromWindow() + } + + override fun onAttachedToWindow() { + hybridPlayer?.movePlayerToVideoView(this) + setupPipHelper() + super.onAttachedToWindow() + } + + private fun PlayerView.configureForSmallPlayer() { + SmallVideoPlayerOptimizer.applyOptimizations(this, context, isFullscreen = false) + + // Also apply after any layout changes + viewTreeObserver.addOnGlobalLayoutListener { + SmallVideoPlayerOptimizer.applyOptimizations(this, context, isFullscreen = false) + } + } + + private fun applySmallPlayerLayoutFixes() { + SmallVideoPlayerOptimizer.applyOptimizations(playerView, context, isFullscreen = false) + } +} diff --git a/android/src/main/res/layout/player_view_surface.xml b/android/src/main/res/layout/player_view_surface.xml new file mode 100644 index 00000000..b8158a3b --- /dev/null +++ b/android/src/main/res/layout/player_view_surface.xml @@ -0,0 +1,14 @@ + diff --git a/android/src/main/res/layout/player_view_texture.xml b/android/src/main/res/layout/player_view_texture.xml new file mode 100644 index 00000000..c0d2588c --- /dev/null +++ b/android/src/main/res/layout/player_view_texture.xml @@ -0,0 +1,15 @@ + \ No newline at end of file diff --git a/android/src/paper/java/com/facebook/react/viewmanagers/RNCVideoViewManagerDelegate.java b/android/src/paper/java/com/facebook/react/viewmanagers/RNCVideoViewManagerDelegate.java new file mode 100644 index 00000000..2a7c45bc --- /dev/null +++ b/android/src/paper/java/com/facebook/react/viewmanagers/RNCVideoViewManagerDelegate.java @@ -0,0 +1,32 @@ +/** +* This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen). +* +* Do not edit this file as changes may cause incorrect behavior and will be lost +* once the code is regenerated. +* +* @generated by codegen project: GeneratePropsJavaDelegate.js +*/ + +package com.facebook.react.viewmanagers; + +import android.view.View; +import androidx.annotation.Nullable; +import com.facebook.react.uimanager.BaseViewManager; +import com.facebook.react.uimanager.BaseViewManagerDelegate; +import com.facebook.react.uimanager.LayoutShadowNode; + +public class RNCVideoViewManagerDelegate & RNCVideoViewManagerInterface> extends BaseViewManagerDelegate { + public RNCVideoViewManagerDelegate(U viewManager) { + super(viewManager); + } + @Override + public void setProperty(T view, String propName, @Nullable Object value) { + switch (propName) { + case "nitroId": + mViewManager.setNitroId(view, value == null ? 0 : ((Double) value).intValue()); + break; + default: + super.setProperty(view, propName, value); + } + } +} diff --git a/android/src/paper/java/com/facebook/react/viewmanagers/RNCVideoViewManagerInterface.java b/android/src/paper/java/com/facebook/react/viewmanagers/RNCVideoViewManagerInterface.java new file mode 100644 index 00000000..fd4069fc --- /dev/null +++ b/android/src/paper/java/com/facebook/react/viewmanagers/RNCVideoViewManagerInterface.java @@ -0,0 +1,16 @@ +/** +* This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen). +* +* Do not edit this file as changes may cause incorrect behavior and will be lost +* once the code is regenerated. +* +* @generated by codegen project: GeneratePropsJavaInterface.js +*/ + +package com.facebook.react.viewmanagers; + +import android.view.View; + +public interface RNCVideoViewManagerInterface { + void setNitroId(T view, int value); +} diff --git a/android/src/stubs/dash/androidx/media3/exoplayer/dash/DashMediaSource.kt b/android/src/stubs/dash/androidx/media3/exoplayer/dash/DashMediaSource.kt new file mode 100644 index 00000000..38106e9f --- /dev/null +++ b/android/src/stubs/dash/androidx/media3/exoplayer/dash/DashMediaSource.kt @@ -0,0 +1,35 @@ +package androidx.media3.exoplayer.dash; + +import androidx.media3.common.MediaItem; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.datasource.DataSource; +import androidx.media3.exoplayer.drm.DrmSessionManagerProvider; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; + +class DashMediaSource { + + @UnstableApi + class Factory( + factory: DataSource.Factory, + ) : MediaSource.Factory { + // NOOP + + override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory { + throw UnsupportedOperationException("STUB") + } + + override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory { + throw UnsupportedOperationException("STUB") + } + + override fun getSupportedTypes(): IntArray { + return intArrayOf() + } + + override fun createMediaSource(mediaItem: MediaItem): MediaSource { + throw UnsupportedOperationException("STUB") + } + } +} + diff --git a/android/src/stubs/dash/androidx/media3/exoplayer/dash/DashUtil.kt b/android/src/stubs/dash/androidx/media3/exoplayer/dash/DashUtil.kt new file mode 100644 index 00000000..0cf634bd --- /dev/null +++ b/android/src/stubs/dash/androidx/media3/exoplayer/dash/DashUtil.kt @@ -0,0 +1,11 @@ +package androidx.media3.exoplayer.dash + +import android.net.Uri +import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.dash.manifest.DashManifest + +object DashUtil { + fun loadManifest(ds: DataSource?, uri: Uri?): DashManifest? { + return null + } +} diff --git a/android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/AdaptationSet.kt b/android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/AdaptationSet.kt new file mode 100644 index 00000000..efe5e9fd --- /dev/null +++ b/android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/AdaptationSet.kt @@ -0,0 +1,9 @@ +package androidx.media3.exoplayer.dash.manifest + +import androidx.collection.CircularArray + +class AdaptationSet { + var type: Int = 0 + var representations: CircularArray? = + null +} diff --git a/android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/DashManifest.kt b/android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/DashManifest.kt new file mode 100644 index 00000000..d9bf6425 --- /dev/null +++ b/android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/DashManifest.kt @@ -0,0 +1,10 @@ +package androidx.media3.exoplayer.dash.manifest + +class DashManifest { + val periodCount: Int + get() = 0 + + fun getPeriod(index: Int): Period? { + return null + } +} diff --git a/android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/Period.kt b/android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/Period.kt new file mode 100644 index 00000000..0f837858 --- /dev/null +++ b/android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/Period.kt @@ -0,0 +1,7 @@ +package androidx.media3.exoplayer.dash.manifest + +import androidx.collection.CircularArray + +class Period { + var adaptationSets: CircularArray? = null +} diff --git a/android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/Representation.kt b/android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/Representation.kt new file mode 100644 index 00000000..434d7132 --- /dev/null +++ b/android/src/stubs/dash/androidx/media3/exoplayer/dash/manifest/Representation.kt @@ -0,0 +1,8 @@ +package androidx.media3.exoplayer.dash.manifest + +import androidx.media3.common.Format + +class Representation { + var format: Format? = null + var presentationTimeOffsetUs: Long = 0 +} diff --git a/android/src/stubs/hls/androidx/media3/exoplayer/hls/HlsMediaSource.kt b/android/src/stubs/hls/androidx/media3/exoplayer/hls/HlsMediaSource.kt new file mode 100644 index 00000000..df7651ad --- /dev/null +++ b/android/src/stubs/hls/androidx/media3/exoplayer/hls/HlsMediaSource.kt @@ -0,0 +1,33 @@ +package androidx.media3.exoplayer.hls + +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.drm.DrmSessionManagerProvider +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy + +class HlsMediaSource { + @UnstableApi + class Factory(private val mediaDataSourceFactory: DataSource.Factory) : MediaSource.Factory { + override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory { + throw UnsupportedOperationException("Not implemented") + } + + override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory { + throw UnsupportedOperationException("Not implemented") + } + + override fun getSupportedTypes(): IntArray { + return intArrayOf() + } + + override fun createMediaSource(mediaItem: MediaItem): MediaSource { + throw UnsupportedOperationException("Not implemented") + } + + fun setAllowChunklessPreparation(allowChunklessPreparation: Boolean): Factory { + return this + } + } +} diff --git a/app.plugin.js b/app.plugin.js new file mode 100644 index 00000000..162bcd89 --- /dev/null +++ b/app.plugin.js @@ -0,0 +1 @@ +module.exports = require('./lib/commonjs/expo-plugins/withReactNativeVideo'); diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 00000000..29f3a606 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,5 @@ +module.exports = { + presets: [ + ['module:react-native-builder-bob/babel-preset', { modules: 'commonjs' }], + ], +}; diff --git a/ios/Video-Bridging-Header.h b/ios/Video-Bridging-Header.h new file mode 100644 index 00000000..16eb5eb1 --- /dev/null +++ b/ios/Video-Bridging-Header.h @@ -0,0 +1 @@ +#import diff --git a/ios/core/Extensions/AVAsset+estimatedMemoryUsage.swift b/ios/core/Extensions/AVAsset+estimatedMemoryUsage.swift new file mode 100644 index 00000000..fdab9264 --- /dev/null +++ b/ios/core/Extensions/AVAsset+estimatedMemoryUsage.swift @@ -0,0 +1,51 @@ +// +// AVAsset+estimatedMemoryUsage.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 23/01/2025. +// + +import AVFoundation +import Foundation + +extension AVAsset { + var estimatedMemoryUsage: Int { + if let urlAsset = self as? AVURLAsset, urlAsset.url.isFileURL { + let resourceValues = try? urlAsset.url.resourceValues(forKeys: [.fileSizeKey]) + if let fileSize = resourceValues?.fileSize, fileSize > 0 { + return fileSize + } + } + + var estimatedSize = 0 + + let videoTracks = tracks(withMediaType: .video) + let audioTracks = tracks(withMediaType: .audio) + + for track in videoTracks { + let size = track.naturalSize + let pixelCount = size.width * size.height + let duration = CMTimeGetSeconds(track.timeRange.duration) + + if duration > 0 && !duration.isNaN && !duration.isInfinite { + let bitrate = track.estimatedDataRate > 0 ? track.estimatedDataRate : 2_000_000 + estimatedSize += Int((bitrate * Float(duration)) / 8) + } else { + estimatedSize += Int(pixelCount * 4) + } + } + + for track in audioTracks { + let duration = CMTimeGetSeconds(track.timeRange.duration) + let bitrate = track.estimatedDataRate > 0 ? track.estimatedDataRate : 128_000 + + if duration > 0 && !duration.isNaN && !duration.isInfinite { + estimatedSize += Int((bitrate * Float(duration)) / 8) + } else { + estimatedSize += 1_000_000 + } + } + + return estimatedSize > 0 ? estimatedSize : 0 + } +} diff --git a/ios/core/Extensions/AVAssetTrack+orientation.swift b/ios/core/Extensions/AVAssetTrack+orientation.swift new file mode 100644 index 00000000..959eba51 --- /dev/null +++ b/ios/core/Extensions/AVAssetTrack+orientation.swift @@ -0,0 +1,41 @@ +// +// AVAssetTrack+orientation.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 23/01/2025. +// + +import AVFoundation + +extension AVAssetTrack { + var orientation: VideoOrientation { + let transform = preferredTransform + let size = naturalSize.applying(transform) + + // Check if video is square + if size.width == size.height { + return .square + } + + // Check if video is portrait or landscape + let isNaturalSizePortrait = size.width < size.height + + // Calculate video rotation + let angle = atan2(Double(transform.b), Double(transform.a)) + let degrees = angle * 180 / .pi + let rotation = degrees < 0 ? degrees + 360 : degrees + + switch rotation { + case 0: + return isNaturalSizePortrait ? .portrait : .landscapeRight + case 90, -270: + return .portrait + case 180, -180: + return isNaturalSizePortrait ? .portraitUpsideDown : .landscapeLeft + case 270, -90: + return .portraitUpsideDown + default: + return isNaturalSizePortrait ? .portrait : .landscape + } + } +} \ No newline at end of file diff --git a/ios/core/Extensions/AVMetadataItem+make.swift b/ios/core/Extensions/AVMetadataItem+make.swift new file mode 100644 index 00000000..ce134218 --- /dev/null +++ b/ios/core/Extensions/AVMetadataItem+make.swift @@ -0,0 +1,18 @@ +// +// AVMetadataItem+make.swift +// ReactNativeVideo +// +// Created by Kamil Moskała on 06/02/2026. +// + +import AVFoundation + +extension AVMetadataItem { + static func make(identifier: AVMetadataIdentifier, value: NSObjectProtocol & NSCopying) -> AVMutableMetadataItem { + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value + item.extendedLanguageTag = "und" + return item + } +} diff --git a/ios/core/Extensions/AVPlayerItem+externalSubtitles.swift b/ios/core/Extensions/AVPlayerItem+externalSubtitles.swift new file mode 100644 index 00000000..39192d2f --- /dev/null +++ b/ios/core/Extensions/AVPlayerItem+externalSubtitles.swift @@ -0,0 +1,35 @@ +// +// AVPlayerItem+externalSubtitles.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 08/05/2025. +// + +import AVFoundation +import Foundation + +extension AVPlayerItem { + static func withExternalSubtitles(for asset: AVURLAsset, config: NativeVideoConfig) async throws + -> AVPlayerItem + { + if config.externalSubtitles?.isEmpty != false { + return AVPlayerItem(asset: asset) + } + + let supportedExternalSubtitles = config.externalSubtitles?.filter { subtitle in + ExternalSubtitlesUtils.isSubtitleTypeSupported(subtitle: subtitle) + } + + if supportedExternalSubtitles?.isEmpty == true { + return AVPlayerItem(asset: asset) + } + + if asset.url.pathExtension == "m3u8" { + return try await ExternalSubtitlesUtils.modifyStreamManifestWithExternalSubtitles( + for: asset, config: config) + } + + return try await ExternalSubtitlesUtils.createCompositionWithExternalSubtitles( + for: asset, config: config) + } +} diff --git a/ios/core/Extensions/AVPlayerItem+getBufferedDurration.swift b/ios/core/Extensions/AVPlayerItem+getBufferedDurration.swift new file mode 100644 index 00000000..b6e7a5b2 --- /dev/null +++ b/ios/core/Extensions/AVPlayerItem+getBufferedDurration.swift @@ -0,0 +1,34 @@ +// +// AVPlayerItem+getBufferedPosition.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 06/05/2025. +// + +import Foundation +import AVFoundation + +extension AVPlayerItem { + + // Duration that can be played using only the buffer (seconds) + func getbufferDuration() -> Double { + var effectiveTimeRange: CMTimeRange? + + for value in loadedTimeRanges { + let timeRange: CMTimeRange = value.timeRangeValue + if CMTimeRangeContainsTime(timeRange, time: currentTime()) { + effectiveTimeRange = timeRange + break + } + } + + if let effectiveTimeRange { + let playableDuration: Float64 = CMTimeGetSeconds(CMTimeRangeGetEnd(effectiveTimeRange)) + if playableDuration > 0 { + return playableDuration + } + } + + return 0 + } +} diff --git a/ios/core/Extensions/AVPlayerItem+setBufferConfig.swift b/ios/core/Extensions/AVPlayerItem+setBufferConfig.swift new file mode 100644 index 00000000..428c1275 --- /dev/null +++ b/ios/core/Extensions/AVPlayerItem+setBufferConfig.swift @@ -0,0 +1,37 @@ +// +// AVPlayerItem+setBufferConfig.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 13/09/2025. +// + +import Foundation +import AVFoundation + +extension AVPlayerItem { + func setBufferConfig(config: BufferConfig) { + if let forwardBufferDurationMs = config.preferredForwardBufferDurationMs { + preferredForwardBufferDuration = TimeInterval(forwardBufferDurationMs / 1000.0) + } + + if let peakBitRate = config.preferredPeakBitRate { + preferredPeakBitRate = Double(peakBitRate) + } + + if let maximumResolution = config.preferredMaximumResolution { + preferredMaximumResolution = CGSize(width: maximumResolution.width, height: maximumResolution.height) + } + + if let peakBitRateForExpensiveNetworks = config.preferredPeakBitRateForExpensiveNetworks { + preferredPeakBitRateForExpensiveNetworks = Double(peakBitRateForExpensiveNetworks) + } + + if let maximumResolutionForExpensiveNetworks = config.preferredMaximumResolutionForExpensiveNetworks { + preferredMaximumResolutionForExpensiveNetworks = CGSize(width: maximumResolutionForExpensiveNetworks.width, height: maximumResolutionForExpensiveNetworks.height) + } + + if let liveTargetOffsetMs = config.livePlayback?.targetOffsetMs { + configuredTimeOffsetFromLive = CMTime(seconds: Double(liveTargetOffsetMs) / 1000.0, preferredTimescale: 1000) + } + } +} diff --git a/ios/core/Extensions/AVPlayerViewController+Fullscreen.swift b/ios/core/Extensions/AVPlayerViewController+Fullscreen.swift new file mode 100644 index 00000000..e4b9342f --- /dev/null +++ b/ios/core/Extensions/AVPlayerViewController+Fullscreen.swift @@ -0,0 +1,21 @@ +// +// AVPlayerViewController+Fullscreen.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 27/04/2025. +// + +import Foundation +import AVKit + +extension AVPlayerViewController { + // https://stackoverflow.com/a/64466924 + + func enterFullscreen(animated: Bool) { + performIfResponds(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: animated, with: nil) + } + + func exitFullscreen(animated: Bool) { + performIfResponds(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: animated, with: nil) + } +} diff --git a/ios/core/Extensions/AVPlayerViewController+PictureInPicture.swift b/ios/core/Extensions/AVPlayerViewController+PictureInPicture.swift new file mode 100644 index 00000000..ec34962f --- /dev/null +++ b/ios/core/Extensions/AVPlayerViewController+PictureInPicture.swift @@ -0,0 +1,24 @@ +// +// AVPlayerViewController+PictureInPicture.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 27/04/2025. +// + +import Foundation +import AVKit + +extension AVPlayerViewController { + // https://github.com/expo/expo/blob/d37ae17df23c58011a3c5b9f5dedd563bf8e6521/packages/expo-video/ios/VideoView.swift#L110 + func startPictureInPicture() throws { + guard AVPictureInPictureController.isPictureInPictureSupported() else { + throw VideoViewError.pictureInPictureNotSupported.error() + } + + performIfResponds(NSSelectorFromString("startPictureInPicture")) + } + + func stopPictureInPicture() { + performIfResponds(NSSelectorFromString("stopPictureInPicture")) + } +} diff --git a/ios/core/Extensions/AVURLAsset+getAssetInformation.swift b/ios/core/Extensions/AVURLAsset+getAssetInformation.swift new file mode 100644 index 00000000..da905a02 --- /dev/null +++ b/ios/core/Extensions/AVURLAsset+getAssetInformation.swift @@ -0,0 +1,74 @@ +import AVFoundation + +extension AVURLAsset { + func getAssetInformation() async throws -> VideoInformation { + // Default values + var bitrate = Double.nan + var width = Double.nan + var height = Double.nan + var videoDuration: Double = -1 + var isHDR = false + var isLive = false + var orientation: VideoOrientation = .unknown + + let fileSize = try await VideoFileHelper.getFileSize(for: url) + + // Check if asset is live stream + if duration.flags.contains(.indefinite) { + videoDuration = -1.0 + isLive = true + } else { + videoDuration = CMTimeGetSeconds(duration) + isLive = false + } + + if let videoTrack = tracks(withMediaType: .video).first { + let size = videoTrack.naturalSize.applying(videoTrack.preferredTransform) + width = size.width + height = size.height + + bitrate = Double(videoTrack.estimatedDataRate) + + orientation = videoTrack.orientation + + if #available(iOS 14.0, tvOS 14.0, visionOS 1.0, *) { + isHDR = videoTrack.hasMediaCharacteristic(.containsHDRVideo) + } + } else if url.pathExtension == "m3u8" { + // For HLS streams, we cannot get video track information directly + // So we download manifest and try to extract video information from it + + let manifestContent = try await HLSManifestParser.downloadManifest(from: url) + let manifestInfo = try HLSManifestParser.parseM3U8Manifest(manifestContent) + + if let videoStream = manifestInfo.streams.first { + width = Double(videoStream.width ?? Int(Double.nan)) + height = Double(videoStream.height ?? Int(Double.nan)) + bitrate = Double(videoStream.bandwidth ?? Int(Double.nan)) + } + + if width > 0 && height > 0 { + if width == height { + orientation = .square + } else if width > height { + orientation = .landscapeRight + } else if width < height { + orientation = .portrait + } else { + orientation = .unknown + } + } + } + + return VideoInformation( + bitrate: bitrate, + width: width, + height: height, + duration: videoDuration, + fileSize: fileSize, + isHDR: isHDR, + isLive: isLive, + orientation: orientation + ) + } +} diff --git a/ios/core/Extensions/NSObject+PerformIfResponds.swift b/ios/core/Extensions/NSObject+PerformIfResponds.swift new file mode 100644 index 00000000..ac1273d8 --- /dev/null +++ b/ios/core/Extensions/NSObject+PerformIfResponds.swift @@ -0,0 +1,30 @@ +// +// NSObject+PerformIfResponds.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 27/04/2025. +// + +import Foundation + +extension NSObject { + // Safe perform methods for Objective-C selectors + + func performIfResponds(_ selector: Selector) { + if self.responds(to: selector) { + self.perform(selector) + } + } + + func performIfResponds(_ selector: Selector, with object: Any?) { + if self.responds(to: selector) { + self.perform(selector, with: object) + } + } + + func performIfResponds(_ selector: Selector, with object1: Any?, with object2: Any?) { + if self.responds(to: selector) { + self.perform(selector, with: object1, with: object2) + } + } +} diff --git a/ios/core/Extensions/ResizeMode+VideoGravity.swift b/ios/core/Extensions/ResizeMode+VideoGravity.swift new file mode 100644 index 00000000..22a209ac --- /dev/null +++ b/ios/core/Extensions/ResizeMode+VideoGravity.swift @@ -0,0 +1,24 @@ +// +// ResizeMode.swift +// ReactNativeVideo +// +// Created for resizeMode feature +// + +import Foundation +import AVFoundation + +public extension ResizeMode { + func toVideoGravity() -> AVLayerVideoGravity { + switch self { + case .contain: + return .resizeAspect + case .cover: + return .resizeAspectFill + case .stretch: + return .resize + case .none: + return .resizeAspect // Default to aspect ratio if none specified + } + } +} diff --git a/ios/core/HLSSubtitleInjector.swift b/ios/core/HLSSubtitleInjector.swift new file mode 100644 index 00000000..fa036b43 --- /dev/null +++ b/ios/core/HLSSubtitleInjector.swift @@ -0,0 +1,381 @@ +import AVFoundation +import Foundation +import NitroModules + +class HLSSubtitleInjector: NSObject { + private let originalManifestUrl: URL + private let externalSubtitles: [NativeExternalSubtitle] + private var modifiedManifestContent: String? + private static let customScheme = "rnv-hls" + private static let subtitleScheme = "rnv-hls-subtitles" + private static let subtitleGroupID = "rnv-subs" + private static let resourceLoaderQueue = DispatchQueue( + label: "com.nitro.HLSSubtitleInjector.resourceLoaderQueue", + qos: .userInitiated + ) + + init(manifestUrl: URL, externalSubtitles: [NativeExternalSubtitle]) { + self.originalManifestUrl = manifestUrl + self.externalSubtitles = externalSubtitles + super.init() + } + + func createModifiedAsset() -> AVURLAsset { + let customURL = createCustomURL(from: originalManifestUrl) + let asset = AVURLAsset(url: customURL) + asset.resourceLoader.setDelegate( + self, + queue: Self.resourceLoaderQueue + ) + return asset + } + + private func createCustomURL(from originalURL: URL) -> URL { + var components = URLComponents( + url: originalURL, + resolvingAgainstBaseURL: false + )! + components.scheme = Self.customScheme + return components.url! + } + + private func getModifiedManifestContent() async throws -> String { + if let cached = modifiedManifestContent { + return cached + } + + let originalContent = try await HLSManifestParser.downloadManifest(from: originalManifestUrl) + let modifiedContent = try modifyM3U8Content( + originalContent, + with: externalSubtitles + ) + modifiedManifestContent = modifiedContent + return modifiedContent + } + + private func modifyM3U8Content( + _ originalContent: String, + with externalSubtitles: [NativeExternalSubtitle] + ) throws -> String { + let lines = originalContent.components(separatedBy: .newlines) + var modifiedLines: [String] = [] + var foundExtM3U = false + var isAfterVersionOrM3U = false + var hasSubtitleGroup = false + let baseURL = originalManifestUrl.deletingLastPathComponent() + + for line in lines { + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + + if trimmedLine.hasPrefix("#EXTM3U") { + foundExtM3U = true + modifiedLines.append(line) + isAfterVersionOrM3U = true + continue + } + + if isAfterVersionOrM3U && !hasSubtitleGroup + && shouldInsertSubtitlesHere(line: trimmedLine) + { + for (index, subtitle) in externalSubtitles.enumerated() { + let subtitleTrack = createSubtitleTrackEntry(for: subtitle, index: index) + modifiedLines.append(subtitleTrack) + } + hasSubtitleGroup = true + isAfterVersionOrM3U = false + } + + let processedLine = HLSManifestParser.convertRelativeURLsToAbsolute( + line: line, + baseURL: baseURL + ) + + // Handle existing subtitle groups and stream info lines + if trimmedLine.hasPrefix("#EXT-X-MEDIA:") && trimmedLine.contains("TYPE=SUBTITLES") { + let modifiedMediaLine = replaceSubtitleGroupInMediaLine(processedLine) + modifiedLines.append(modifiedMediaLine) + } else if trimmedLine.hasPrefix("#EXT-X-STREAM-INF:") { + let modifiedStreamLine = replaceSubtitleGroupInStreamInf( + processedLine, hasSubtitleGroup: hasSubtitleGroup) + modifiedLines.append(modifiedStreamLine) + } else { + modifiedLines.append(processedLine) + } + } + + if foundExtM3U && !hasSubtitleGroup { + var finalLines: [String] = [] + var insertedSubtitles = false + + for line in modifiedLines { + finalLines.append(line) + + if !insertedSubtitles + && (line.hasPrefix("#EXTM3U") || line.hasPrefix("#EXT-X-VERSION")) + { + for (index, subtitle) in externalSubtitles.enumerated() { + let subtitleTrack = createSubtitleTrackEntry(for: subtitle, index: index) + finalLines.append(subtitleTrack) + } + insertedSubtitles = true + } + } + + modifiedLines = finalLines + } + + if !foundExtM3U { + throw SourceError.invalidUri(uri: originalManifestUrl.absoluteString) + .error() + } + + // Post-process: ensure every variant stream references our subtitle group if we injected it + if hasSubtitleGroup { + modifiedLines = modifiedLines.map { line in + if line.hasPrefix("#EXT-X-STREAM-INF:") && !line.contains("SUBTITLES=") { + if line.hasSuffix(",") { + return line + "SUBTITLES=\"\(Self.subtitleGroupID)\"" + } else { + return line + ",SUBTITLES=\"\(Self.subtitleGroupID)\"" + } + } + return line + } + } + + return modifiedLines.joined(separator: "\n") + } + + private func shouldInsertSubtitlesHere(line: String) -> Bool { + return line.hasPrefix("#EXT-X-STREAM-INF:") + || line.hasPrefix("#EXT-X-I-FRAME-STREAM-INF:") + || line.hasPrefix("#EXT-X-MEDIA:") + || line.hasPrefix("#EXTINF:") + || line.hasPrefix("#EXT-X-BYTERANGE:") + || (!line.hasPrefix("#") && !line.isEmpty + && !line.hasPrefix("#EXT-X-VERSION")) + } + + private func replaceSubtitleGroupInMediaLine(_ line: String) -> String { + // Find and replace GROUP-ID in subtitle media lines + let groupIdPattern = #"GROUP-ID="[^"]*""# + + if let regex = try? NSRegularExpression(pattern: groupIdPattern, options: []) { + let range = NSRange(location: 0, length: line.utf16.count) + let replacement = "GROUP-ID=\"\(Self.subtitleGroupID)\"" + return regex.stringByReplacingMatches( + in: line, options: [], range: range, withTemplate: replacement) + } + + return line + } + + private func replaceSubtitleGroupInStreamInf(_ line: String, hasSubtitleGroup: Bool) -> String { + // First, handle existing SUBTITLES= references + let subtitlesPattern = #"SUBTITLES="[^"]*""# + + var modifiedLine = line + if let regex = try? NSRegularExpression(pattern: subtitlesPattern, options: []) { + let range = NSRange(location: 0, length: line.utf16.count) + let replacement = "SUBTITLES=\"\(Self.subtitleGroupID)\"" + modifiedLine = regex.stringByReplacingMatches( + in: line, options: [], range: range, withTemplate: replacement) + } else if hasSubtitleGroup && !line.contains("SUBTITLES=") { + // Add subtitle group reference if we have subtitles but no existing reference + if line.hasSuffix(",") { + modifiedLine = line + "SUBTITLES=\"\(Self.subtitleGroupID)\"" + } else { + modifiedLine = line + ",SUBTITLES=\"\(Self.subtitleGroupID)\"" + } + } + + return modifiedLine + } + + private func createSubtitleTrackEntry(for subtitle: NativeExternalSubtitle, index: Int) + -> String + { + let subtitleM3U8URI = "\(Self.subtitleScheme)://\(index)/subtitle.m3u8" + + return + "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"\(Self.subtitleGroupID)\",NAME=\"\(subtitle.label)\",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE=\"\(subtitle.language)\",URI=\"\(subtitleM3U8URI)\"" + } +} + +// MARK: - AVAssetResourceLoaderDelegate + +extension HLSSubtitleInjector: AVAssetResourceLoaderDelegate { + func resourceLoader( + _ resourceLoader: AVAssetResourceLoader, + shouldWaitForLoadingOfRequestedResource loadingRequest: + AVAssetResourceLoadingRequest + ) -> Bool { + + guard let url = loadingRequest.request.url else { + return false + } + + switch url.scheme { + case Self.customScheme: + return handleMainManifest(url: url, loadingRequest: loadingRequest) + case Self.subtitleScheme: + return handleSubtitleM3U8(url: url, loadingRequest: loadingRequest) + default: + return false + } + } + + private func handleMainManifest(url: URL, loadingRequest: AVAssetResourceLoadingRequest) -> Bool { + guard url.path.hasSuffix(".m3u8") else { + return false + } + + Task { + do { + let modifiedContent = try await getModifiedManifestContent() + + guard let data = modifiedContent.data(using: .utf8) else { + throw SourceError.invalidUri(uri: "Failed to encode manifest content") + .error() + } + + if let contentRequest = loadingRequest.contentInformationRequest { + contentRequest.contentType = "application/x-mpegURL" + contentRequest.contentLength = Int64(data.count) + contentRequest.isByteRangeAccessSupported = true + } + + if let dataRequest = loadingRequest.dataRequest { + let requestedData: Data + + if dataRequest.requestedOffset > 0 || dataRequest.requestedLength > 0 { + let offset = Int(dataRequest.requestedOffset) + let length = + dataRequest.requestedLength > 0 + ? min(Int(dataRequest.requestedLength), data.count - offset) + : data.count - offset + + if offset < data.count && length > 0 { + requestedData = data.subdata(in: offset..<(offset + length)) + } else { + requestedData = Data() + } + } else { + requestedData = data + } + + dataRequest.respond(with: requestedData) + } + + loadingRequest.finishLoading() + } catch { + loadingRequest.finishLoading(with: error) + } + } + + return true + } + + private func handleSubtitleM3U8(url: URL, loadingRequest: AVAssetResourceLoadingRequest) -> Bool { + guard let indexString = url.host, let index = Int(indexString) else { + return false + } + + guard index < externalSubtitles.count else { + return false + } + + let subtitle = externalSubtitles[index] + + Task { + do { + guard let subtitleURL = URL(string: subtitle.uri) else { + throw SourceError.invalidUri(uri: "Invalid subtitle URI: \(subtitle.uri)").error() + } + + let (vttData, response) = try await URLSession.shared.data(from: subtitleURL) + + guard let httpResponse = response as? HTTPURLResponse, + 200...299 ~= httpResponse.statusCode + else { + throw SourceError.invalidUri(uri: "Subtitle request failed with status: \(response)") + .error() + } + + guard let vttString = String(data: vttData, encoding: .utf8) else { + throw SourceError.invalidUri(uri: "Failed to decode VTT content").error() + } + + let duration = extractDurationFromVTT(vttString) + + let m3u8Wrapper = """ + #EXTM3U + #EXT-X-VERSION:3 + #EXT-X-MEDIA-SEQUENCE:1 + #EXT-X-PLAYLIST-TYPE:VOD + #EXT-X-ALLOW-CACHE:NO + #EXT-X-TARGETDURATION:\(Int(duration)) + #EXTINF:\(String(format: "%.3f", duration)), no desc + \(subtitle.uri) + #EXT-X-ENDLIST + """ + + guard let m3u8Data = m3u8Wrapper.data(using: .utf8) else { + throw SourceError.invalidUri(uri: "Failed to create M3U8 wrapper").error() + } + + if let contentRequest = loadingRequest.contentInformationRequest { + contentRequest.contentType = "application/x-mpegURL" + contentRequest.contentLength = Int64(m3u8Data.count) + contentRequest.isByteRangeAccessSupported = true + } + + if let dataRequest = loadingRequest.dataRequest { + dataRequest.respond(with: m3u8Data) + } + + loadingRequest.finishLoading() + } catch { + loadingRequest.finishLoading(with: error) + } + } + + return true + } + + private func extractDurationFromVTT(_ vttString: String) -> Double { + // Extract duration from VTT timestamps (similar to the PR approach) + let timestampPattern = #"(?:(\d+):)?(\d+):([\d\.]+)"# + + guard let regex = try? NSRegularExpression(pattern: timestampPattern, options: []) else { + return 60.0 // Default fallback + } + + let matches = regex.matches( + in: vttString, + options: [], + range: NSRange(location: 0, length: vttString.utf16.count) + ) + + guard let lastMatch = matches.last, + let range = Range(lastMatch.range, in: vttString) + else { + return 60.0 // Default fallback + } + + let lastTimestampString = String(vttString[range]) + let components = lastTimestampString.components(separatedBy: ":").reversed() + .compactMap { Double($0) } + .enumerated() + .map { pow(60.0, Double($0.offset)) * $0.element } + .reduce(0, +) + + return max(components, 1.0) // Ensure at least 1 second + } + + func resourceLoader( + _ resourceLoader: AVAssetResourceLoader, + didCancel loadingRequest: AVAssetResourceLoadingRequest + ) { + } +} diff --git a/ios/core/NowPlayingInfoCenterManager.swift b/ios/core/NowPlayingInfoCenterManager.swift new file mode 100644 index 00000000..7bc9a390 --- /dev/null +++ b/ios/core/NowPlayingInfoCenterManager.swift @@ -0,0 +1,356 @@ +import Foundation +import MediaPlayer + +class NowPlayingInfoCenterManager { + static let shared = NowPlayingInfoCenterManager() + + private let SEEK_INTERVAL_SECONDS: Double = 10 + + private weak var currentPlayer: AVPlayer? + private var players = NSHashTable.weakObjects() + + private var observers: [Int: NSKeyValueObservation] = [:] + private var playbackObserver: Any? + + private var playTarget: Any? + private var pauseTarget: Any? + private var skipForwardTarget: Any? + private var skipBackwardTarget: Any? + private var playbackPositionTarget: Any? + private var togglePlayPauseTarget: Any? + + private let remoteCommandCenter = MPRemoteCommandCenter.shared() + + var receivingRemoteControlEvents = false { + didSet { + if receivingRemoteControlEvents { + DispatchQueue.main.async { [weak self] in + VideoManager.shared.setRemoteControlEventsActive(true) + UIApplication.shared.beginReceivingRemoteControlEvents() + if self?.currentPlayer?.currentItem != nil { + self?.updateNowPlayingInfo() + } + } + } else { + DispatchQueue.main.async { + UIApplication.shared.endReceivingRemoteControlEvents() + VideoManager.shared.setRemoteControlEventsActive(false) + } + } + } + } + + deinit { + cleanup() + } + + func registerPlayer(player: AVPlayer) { + if players.contains(player) { + return + } + + if !receivingRemoteControlEvents { + receivingRemoteControlEvents = true + } + + if let oldObserver = observers[player.hashValue] { + oldObserver.invalidate() + } + + observers[player.hashValue] = observePlayers(player: player) + players.add(player) + + // Also take over if the new player is already playing — KVO won't fire since rate hasn't changed + if currentPlayer == nil || player.rate != 0 { + setCurrentPlayer(player: player) + } + } + + func removePlayer(player: AVPlayer) { + if !players.contains(player) { + return + } + + if let observer = observers[player.hashValue] { + observer.invalidate() + } + + observers.removeValue(forKey: player.hashValue) + players.remove(player) + + if players.allObjects.isEmpty { + cleanup() + return + } + + if currentPlayer == player { + if let playbackObserver { + player.removeTimeObserver(playbackObserver) + self.playbackObserver = nil + } + currentPlayer = nil + findNewCurrentPlayer() + if currentPlayer == nil { + updatePlaybackState() + } + } + } + + public func cleanup() { + observers.removeAll() + players.removeAllObjects() + + if let playbackObserver { + currentPlayer?.removeTimeObserver(playbackObserver) + self.playbackObserver = nil + } + currentPlayer = nil + + invalidateCommandTargets() + + MPNowPlayingInfoCenter.default().nowPlayingInfo = [:] + receivingRemoteControlEvents = false + } + + private func setCurrentPlayer(player: AVPlayer) { + if player == currentPlayer { + return + } + + if let playbackObserver { + currentPlayer?.removeTimeObserver(playbackObserver) + self.playbackObserver = nil + } + + currentPlayer = player + registerCommandTargets() + + updateNowPlayingInfo() + playbackObserver = player.addPeriodicTimeObserver( + forInterval: CMTime(value: 1, timescale: 4), + queue: .main, + using: { [weak self] _ in + self?.updatePlaybackState() + } + ) + } + + private func registerCommandTargets() { + invalidateCommandTargets() + + playTarget = remoteCommandCenter.playCommand.addTarget { [weak self] _ in + guard let self, let player = self.currentPlayer else { + return .commandFailed + } + + if player.rate == 0 { + player.play() + } + + return .success + } + + pauseTarget = remoteCommandCenter.pauseCommand.addTarget { [weak self] _ in + guard let self, let player = self.currentPlayer else { + return .commandFailed + } + + if player.rate != 0 { + player.pause() + } + + return .success + } + + skipBackwardTarget = remoteCommandCenter.skipBackwardCommand.addTarget { + [weak self] _ in + guard let self, let player = self.currentPlayer else { + return .commandFailed + } + let newTime = + player.currentTime() + - CMTime(seconds: self.SEEK_INTERVAL_SECONDS, preferredTimescale: .max) + player.seek(to: newTime) + return .success + } + + skipForwardTarget = remoteCommandCenter.skipForwardCommand.addTarget { + [weak self] _ in + guard let self, let player = self.currentPlayer else { + return .commandFailed + } + + let newTime = + player.currentTime() + + CMTime(seconds: self.SEEK_INTERVAL_SECONDS, preferredTimescale: .max) + player.seek(to: newTime) + return .success + } + + playbackPositionTarget = remoteCommandCenter.changePlaybackPositionCommand + .addTarget { [weak self] event in + guard let self, let player = self.currentPlayer else { + return .commandFailed + } + if let event = event as? MPChangePlaybackPositionCommandEvent { + player.seek( + to: CMTime(seconds: event.positionTime, preferredTimescale: .max) + ) + return .success + } + return .commandFailed + } + + // Handler for togglePlayPauseCommand, sent by Apple's Earpods wired headphones + togglePlayPauseTarget = remoteCommandCenter.togglePlayPauseCommand.addTarget + { [weak self] _ in + guard let self, let player = self.currentPlayer else { + return .commandFailed + } + + if player.rate == 0 { + player.play() + } else { + player.pause() + } + + return .success + } + } + + private func invalidateCommandTargets() { + remoteCommandCenter.playCommand.removeTarget(playTarget) + remoteCommandCenter.pauseCommand.removeTarget(pauseTarget) + remoteCommandCenter.skipForwardCommand.removeTarget(skipForwardTarget) + remoteCommandCenter.skipBackwardCommand.removeTarget(skipBackwardTarget) + remoteCommandCenter.changePlaybackPositionCommand.removeTarget( + playbackPositionTarget + ) + remoteCommandCenter.togglePlayPauseCommand.removeTarget( + togglePlayPauseTarget + ) + } + + public func updateNowPlayingInfo() { + updateStaticInfo() + updatePlaybackState() + } + + func updateStaticInfo(ifCurrentItem playerItem: AVPlayerItem) { + guard currentPlayer?.currentItem === playerItem else { return } + updateStaticInfo() + } + + func updateStaticInfo() { + guard let player = currentPlayer, let currentItem = player.currentItem else { + return + } + + // commonMetadata is metadata from asset, externalMetadata is custom metadata set by user + // externalMetadata should override commonMetadata to allow override metadata from source + // When the metadata has the tag "iTunSMPB" or "iTunNORM" then the metadata is not converted correctly and comes [nil, nil, ...] + // This leads to a crash of the app + let metadata: [AVMetadataItem] = { + let common = processMetadataItems(currentItem.asset.commonMetadata) + let external = processMetadataItems(currentItem.externalMetadata) + return Array(common.merging(external) { _, new in new }.values) + }() + + let title = AVMetadataItem.metadataItems( + from: metadata, + filteredByIdentifier: .commonIdentifierTitle + ).first?.stringValue ?? "" + + let artist = AVMetadataItem.metadataItems( + from: metadata, + filteredByIdentifier: .commonIdentifierArtist + ).first?.stringValue ?? "" + + var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + info[MPMediaItemPropertyTitle] = title + info[MPMediaItemPropertyArtist] = artist + info[MPMediaItemPropertyPlaybackDuration] = currentItem.duration.seconds + info[MPNowPlayingInfoPropertyIsLiveStream] = CMTIME_IS_INDEFINITE(currentItem.asset.duration) + info[MPMediaItemPropertyArtwork] = nil // Clear artwork from previous item; will be loaded asynchronously below + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + + // Load artwork asynchronously so notification controls appear immediately. + guard let artworkMetadataItem = AVMetadataItem.metadataItems( + from: metadata, + filteredByIdentifier: .commonIdentifierArtwork + ).first else { return } + + Task { [weak self, weak player, weak currentItem] in + guard let data = try? await artworkMetadataItem.load(.dataValue), + let image = UIImage(data: data) else { return } + let artworkItem = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + await MainActor.run { + guard let self, self.currentPlayer === player, + self.currentPlayer?.currentItem === currentItem else { return } + var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + info[MPMediaItemPropertyArtwork] = artworkItem + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } + } + } + + func updatePlaybackState() { + guard let player = currentPlayer else { + invalidateCommandTargets() + MPNowPlayingInfoCenter.default().nowPlayingInfo = [:] + return + } + + guard let currentItem = player.currentItem else { return } + + var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentItem.currentTime().seconds + info[MPNowPlayingInfoPropertyPlaybackRate] = player.rate + info[MPMediaItemPropertyPlaybackDuration] = currentItem.duration.seconds + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } + + private func findNewCurrentPlayer() { + if let newPlayer = players.allObjects.first(where: { + $0.rate != 0 + }) { + setCurrentPlayer(player: newPlayer) + } + } + + // We will observe players rate to find last active player that info will be displayed + private func observePlayers(player: AVPlayer) -> NSKeyValueObservation { + return player.observe(\.rate) { [weak self] player, _ in + guard let self else { return } + + let rate = player.rate + + // case where there is new player that is not paused + // In this case event is triggered by non currentPlayer + if rate != 0 && self.currentPlayer != player { + self.setCurrentPlayer(player: player) + return + } + + // case where currentPlayer was paused + // In this case event is triggered by currentPlayer + if rate == 0 && self.currentPlayer == player { + self.findNewCurrentPlayer() + } + } + } + + private func processMetadataItems(_ items: [AVMetadataItem]) -> [String: + AVMetadataItem] + { + var result = [String: AVMetadataItem]() + + for item in items { + if let id = item.identifier?.rawValue, !id.isEmpty, result[id] == nil { + result[id] = item + } + } + + return result + } +} diff --git a/ios/core/Plugins/PluginsRegistry.swift b/ios/core/Plugins/PluginsRegistry.swift new file mode 100644 index 00000000..2b712a2c --- /dev/null +++ b/ios/core/Plugins/PluginsRegistry.swift @@ -0,0 +1,102 @@ +// +// PluginRegistry.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 22/07/2025. +// + +import AVFoundation +import Foundation + +public final class PluginsRegistry { + public static let shared = PluginsRegistry() + + // Plugin ID -> ReactNativeVideoPluginSpec + private var plugins: [String: ReactNativeVideoPluginSpec] = [:] + + // MARK: - Public API + + public func register(plugin: ReactNativeVideoPluginSpec) { + #if DEBUG + if hasPlugin(plugin: plugin) { + print( + "[ReactNativeVideo] Plugin \(plugin.name) (ID: \(plugin.id)) is already registered - overwriting." + ) + } else { + print( + "[ReactNativeVideo] Registering plugin \(plugin.name) (ID: \(plugin.id))." + ) + } + #endif + + plugins.updateValue(plugin, forKey: plugin.id) + } + + public func unregister(plugin: ReactNativeVideoPluginSpec) { + #if DEBUG + if !hasPlugin(plugin: plugin) { + print( + "[ReactNativeVideo] Plugin \(plugin.name) (ID: \(plugin.id)) is not registered - skipping." + ) + } else { + print("[ReactNativeVideo] Unregistering plugin \(plugin.name) (ID: \(plugin.id)).") + } + #endif + + plugins.removeValue(forKey: plugin.id) + } + + // MARK: - Internal API + + private func hasPlugin(plugin: ReactNativeVideoPluginSpec) -> Bool { + return plugins.contains { $0.value.id == plugin.id } + } + + internal func getDrmManager(source: NativeVideoPlayerSource) throws -> DRMManagerSpec? { + for plugin in plugins.values { + if let drmManager = plugin.getDRMManager(source: source) { + return drmManager + } + } + + throw LibraryError.DRMPluginNotFound.error() + } + + internal func overrideSource(source: NativeVideoPlayerSource) async + -> NativeVideoPlayerSource + { + var overriddenSource = source + + for plugin in plugins.values { + overriddenSource = await plugin.overrideSource(source: overriddenSource) + } + + return overriddenSource + } + + // MARK: - Notifications + + internal func notifyPlayerCreated(player: NativeVideoPlayer) { + for plugin in plugins.values { + plugin.onPlayerCreated(player: Weak(value: player)) + } + } + + internal func notifyPlayerDestroyed(player: NativeVideoPlayer) { + for plugin in plugins.values { + plugin.onPlayerDestroyed(player: Weak(value: player)) + } + } + + internal func notifyVideoViewCreated(view: VideoComponentView) { + for plugin in plugins.values { + plugin.onVideoViewCreated(view: Weak(value: view)) + } + } + + internal func notifyVideoViewDestroyed(view: VideoComponentView) { + for plugin in plugins.values { + plugin.onVideoViewDestroyed(view: Weak(value: view)) + } + } +} diff --git a/ios/core/Plugins/ReactNativeVideoPlugin.swift b/ios/core/Plugins/ReactNativeVideoPlugin.swift new file mode 100644 index 00000000..24a111e9 --- /dev/null +++ b/ios/core/Plugins/ReactNativeVideoPlugin.swift @@ -0,0 +1,91 @@ +// +// ReactNativeVideoPlugin.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 22/07/2025. +// + +import AVFoundation +import Foundation + +public protocol ReactNativeVideoPluginSpec { + /** + * The ID of the plugin. + */ + var id: String { get } + + /** + * The name of the plugin. + */ + var name: String { get } + + /** + * Called when a player is created. + * + * @param player The weak reference to the player instance. + */ + func onPlayerCreated(player: Weak) + + /** + * Called when a player is destroyed. + * + * @param player The weak reference to the player instance. + */ + func onPlayerDestroyed(player: Weak) + + /** + * Called when a video view is created. + * + * @param view The weak reference to the video view instance. + */ + func onVideoViewCreated(view: Weak) + + /** + * Called when a video view is destroyed. + * + * @param view The weak reference to the video view instance. + */ + func onVideoViewDestroyed(view: Weak) + + /** + * Called when a source is overridden. + * + * @param source The source instance. + * @return The overridden source instance. + */ + func overrideSource(source: NativeVideoPlayerSource) async -> NativeVideoPlayerSource + + /** + * Called when a DRM manager is requested. + * + * @param source The source instance. + * @return The DRM manager instance. + */ + func getDRMManager(source: NativeVideoPlayerSource) -> DRMManagerSpec? +} + +open class ReactNativeVideoPlugin: ReactNativeVideoPluginSpec { + public let id: String + public let name: String + + public init(name: String) { + self.name = name + self.id = "RNV_Plugin_\(name)" + + PluginsRegistry.shared.register(plugin: self) + } + + open func onPlayerCreated(player: Weak) { /* no-op */ } + open func onPlayerDestroyed(player: Weak) { /* no-op */ } + + open func onVideoViewCreated(view: Weak) { /* no-op */ } + open func onVideoViewDestroyed(view: Weak) { /* no-op */ } + + open func overrideSource(source: NativeVideoPlayerSource) async -> NativeVideoPlayerSource { + return source + } + + open func getDRMManager(source: NativeVideoPlayerSource) -> DRMManagerSpec? { + return nil + } +} diff --git a/ios/core/Spec/DRMManagerSpec.swift b/ios/core/Spec/DRMManagerSpec.swift new file mode 100644 index 00000000..105703b5 --- /dev/null +++ b/ios/core/Spec/DRMManagerSpec.swift @@ -0,0 +1,14 @@ +// +// DRMManagerSpec.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 05/08/2025. +// + +import Foundation +import AVFoundation + +public protocol DRMManagerSpec: AVContentKeySessionDelegate { + /// Creates a content key request for the given asset and DRM parameters. + func createContentKeyRequest(for asset: AVURLAsset, drmParams: NativeDrmParams) throws +} diff --git a/ios/core/Spec/NativeVideoPlayerSourceSpec.swift b/ios/core/Spec/NativeVideoPlayerSourceSpec.swift new file mode 100644 index 00000000..524d9f88 --- /dev/null +++ b/ios/core/Spec/NativeVideoPlayerSourceSpec.swift @@ -0,0 +1,36 @@ +// +// NativeVideoPlayerSourceSpec.swift +// react-native-video +// +// Created by Krzysztof Moch on 23/07/2025. +// + +import Foundation +import AVFoundation + +// Helper alias that allow to represet player source outside of module +public typealias NativeVideoPlayerSource = NativeVideoPlayerSourceSpec & HybridVideoPlayerSourceSpec + +public protocol NativeVideoPlayerSourceSpec { + // MARK: - Properties + + /// The underlying AVURLAsset instance + var asset: AVURLAsset? { get set } + + /// The URL of the video source + var url: URL { get } + + /// The memory size used by the source + var memorySize: Int { get } + + // MARK: - Methods + + /// Initialize the asset asynchronously + func initializeAsset() async throws + + /// Get non-null AVURLAsset instance (self.asset) + func getAsset() async throws -> AVURLAsset + + /// Release the asset resources + func releaseAsset() +} diff --git a/ios/core/Spec/NativeVideoPlayerSpec.swift b/ios/core/Spec/NativeVideoPlayerSpec.swift new file mode 100644 index 00000000..674ae58b --- /dev/null +++ b/ios/core/Spec/NativeVideoPlayerSpec.swift @@ -0,0 +1,42 @@ +// +// NativeVideoPlayerSpec.swift +// react-native-video +// +// Created by Krzysztof Moch on 09/10/2024. +// + +import Foundation +import AVFoundation + +// Helper alias that allow to represet player outside of module +public typealias NativeVideoPlayer = NativeVideoPlayerSpec & HybridVideoPlayerSpec + +public protocol NativeVideoPlayerSpec { + // MARK: - Properties + + /// The underlying AVPlayer instance (should not be used directly) + var player: AVPlayer { get set } + + /// The current player item + var playerItem: AVPlayerItem? { get set } + + /// The player observer for monitoring state changes + // var playerObserver: VideoPlayerObserver? { get set } + + /// Whether the player was auto-paused + var wasAutoPaused: Bool { get set } + + /// Whether the player is currently buffering + var isCurrentlyBuffering: Bool { get set } + + /// The memory size used by the player + var memorySize: Int { get } + + // MARK: - Methods + + /// Release the player resources + func release() + + /// Initialize the player item asynchronously + func initializePlayerItem() async throws -> AVPlayerItem +} diff --git a/ios/core/Utils/ExternalSubtitlesUtils.swift b/ios/core/Utils/ExternalSubtitlesUtils.swift new file mode 100644 index 00000000..82c0e168 --- /dev/null +++ b/ios/core/Utils/ExternalSubtitlesUtils.swift @@ -0,0 +1,121 @@ +import AVFoundation +import ObjectiveC + +private var HLSSubtitleInjectorAssociatedKey: UInt8 = 0 + +enum ExternalSubtitlesUtils { + static func isSubtitleTypeSupported(subtitle: NativeExternalSubtitle) -> Bool { + if subtitle.type == .vtt { + return true + } + + if let url = URL(string: subtitle.uri), url.pathExtension == "vtt" { + return true + } + + print("[ReactNativeVideo] Unsupported external subtitle. Expected VTT. uri: \(subtitle.uri)") + return false + } + + static func createCompositionWithExternalSubtitles( + for asset: AVURLAsset, + config: NativeVideoConfig + ) async throws -> AVPlayerItem { + let supportedSubtitles = (config.externalSubtitles ?? []).filter { subtitle in + isSubtitleTypeSupported(subtitle: subtitle) + } + + let subtitles: [(NativeExternalSubtitle, AVURLAsset)] = try supportedSubtitles.map { subtitle in + guard let url = URL(string: subtitle.uri) else { + throw PlayerError.invalidTrackUrl(url: subtitle.uri).error() + } + return (subtitle, AVURLAsset(url: url)) + } + + let mainDuration = try await asset.load(.duration) + + let composition = AVMutableComposition() + + let tracks = try await asset.load(.tracks) + for track in tracks { + guard let compTrack = composition.addMutableTrack( + withMediaType: track.mediaType, + preferredTrackID: kCMPersistentTrackID_Invalid + ) else { continue } + + do { + try compTrack.insertTimeRange( + CMTimeRange(start: .zero, duration: mainDuration), + of: track, + at: .zero + ) + } catch { + print("[ReactNativeVideo] Error inserting main track \(track.mediaType.rawValue): \(error.localizedDescription)") + } + } + + for (subtitle, subtitleAsset) in subtitles { + let track: AVAssetTrack? = try await subtitleAsset.loadTracks(withMediaType: .text).first + + guard let track else { continue } + + guard let compSubtitleTrack = composition.addMutableTrack( + withMediaType: track.mediaType, + preferredTrackID: kCMPersistentTrackID_Invalid + ) else { continue } + + do { + let trackRange = try await track.load(.timeRange) + let effectiveDuration = CMTimeMinimum(trackRange.duration, mainDuration) + try compSubtitleTrack.insertTimeRange( + CMTimeRange(start: .zero, duration: effectiveDuration), + of: track, + at: .zero + ) + + compSubtitleTrack.languageCode = subtitle.language + compSubtitleTrack.isEnabled = true + } catch { + print("[ReactNativeVideo] Error inserting subtitle track: \(error.localizedDescription)") + continue + } + } + + return await AVPlayerItem(asset: composition) + } + + static func modifyStreamManifestWithExternalSubtitles( + for asset: AVURLAsset, + config: NativeVideoConfig + ) async throws -> AVPlayerItem { + guard let externalSubtitles = config.externalSubtitles, + !externalSubtitles.isEmpty + else { + return AVPlayerItem(asset: asset) + } + + let supportedSubtitles = externalSubtitles.filter { subtitle in + isSubtitleTypeSupported(subtitle: subtitle) + } + + guard !supportedSubtitles.isEmpty else { + return AVPlayerItem(asset: asset) + } + + let subtitleInjector = HLSSubtitleInjector( + manifestUrl: asset.url, + externalSubtitles: supportedSubtitles + ) + let modifiedAsset = subtitleInjector.createModifiedAsset() + let playerItem = AVPlayerItem(asset: modifiedAsset) + + objc_setAssociatedObject( + playerItem, + &HLSSubtitleInjectorAssociatedKey, + subtitleInjector, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + + return playerItem + } +} diff --git a/ios/core/Utils/HLSManifestParser.swift b/ios/core/Utils/HLSManifestParser.swift new file mode 100644 index 00000000..757fab51 --- /dev/null +++ b/ios/core/Utils/HLSManifestParser.swift @@ -0,0 +1,170 @@ +import AVFoundation +import Foundation +import NitroModules + +class HLSManifestParser { + + /// Downloads manifest content from the given URL + static func downloadManifest(from url: URL) async throws -> String { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + 200...299 ~= httpResponse.statusCode + else { + throw SourceError.invalidUri(uri: url.absoluteString).error() + } + + guard let manifestContent = String(data: data, encoding: .utf8) else { + throw SourceError.invalidUri(uri: url.absoluteString).error() + } + + return manifestContent + } + + /// Converts relative URLs in a manifest line to absolute URLs + static func convertRelativeURLsToAbsolute(line: String, baseURL: URL) -> String { + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + + if trimmedLine.isEmpty { + return line + } + + if trimmedLine.hasPrefix("#") { + if trimmedLine.contains("URI=") { + return convertURIParametersToAbsolute(line: line, baseURL: baseURL) + } + return line + } + + if !trimmedLine.hasPrefix("http://") && !trimmedLine.hasPrefix("https://") { + let absoluteURL = baseURL.appendingPathComponent(trimmedLine) + return absoluteURL.absoluteString + } + + return line + } + + /// Converts URI parameters in manifest lines to absolute URLs + static func convertURIParametersToAbsolute(line: String, baseURL: URL) -> String { + var modifiedLine = line + let uriPattern = #"URI="([^"]+)""# + + guard let regex = try? NSRegularExpression(pattern: uriPattern, options: []) + else { + return line + } + + let nsLine = line as NSString + let matches = regex.matches( + in: line, + options: [], + range: NSRange(location: 0, length: nsLine.length) + ) + + for match in matches.reversed() { + if match.numberOfRanges >= 2 { + let uriRange = match.range(at: 1) + let uri = nsLine.substring(with: uriRange) + + if !uri.hasPrefix("http://") && !uri.hasPrefix("https://") { + let absoluteURL = baseURL.appendingPathComponent(uri) + let fullRange = match.range(at: 0) + let replacement = "URI=\"\(absoluteURL.absoluteString)\"" + modifiedLine = (modifiedLine as NSString).replacingCharacters( + in: fullRange, + with: replacement + ) + } + } + } + + return modifiedLine + } + + /// Parses M3U8 manifest content and returns parsed information + static func parseM3U8Manifest(_ content: String) throws -> HLSManifestInfo { + let lines = content.components(separatedBy: .newlines) + var info = HLSManifestInfo() + + for line in lines { + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + + if trimmedLine.hasPrefix("#EXTM3U") { + info.isValid = true + } + + // Parse version + if trimmedLine.hasPrefix("#EXT-X-VERSION:") { + let versionString = String(trimmedLine.dropFirst("#EXT-X-VERSION:".count)) + info.version = Int(versionString) + } + + // Parse stream info for resolution + if trimmedLine.hasPrefix("#EXT-X-STREAM-INF:") { + let streamInfo = parseStreamInf(trimmedLine) + info.streams.append(streamInfo) + } + } + + if !info.isValid { + throw SourceError.invalidUri(uri: "Invalid M3U8 format").error() + } + + return info + } + + /// Parses EXT-X-STREAM-INF line to extract stream information + private static func parseStreamInf(_ line: String) -> HLSStreamInfo { + var streamInfo = HLSStreamInfo() + + // Parse RESOLUTION + if let resolutionRange = line.range(of: "RESOLUTION=") { + let afterResolution = line[resolutionRange.upperBound...] + if let commaRange = afterResolution.range(of: ",") { + let resolutionValue = String(afterResolution[..` class is designed to hold a weak reference to an object of type `T`. +/// This is particularly useful in scenarios where strong references could lead to retain cycles +/// or memory leaks, such as in plugin systems or delegate patterns. +/// +/// - Note: The `value` property provides access to the referenced object, or `nil` if the object has been deallocated. +public class Weak { + private weak var _value: AnyObject? + public var value: T? { + return _value as? T + } + + public init(value: T) { + _value = value as AnyObject + } +} diff --git a/ios/core/VideoError.swift b/ios/core/VideoError.swift new file mode 100644 index 00000000..cb016388 --- /dev/null +++ b/ios/core/VideoError.swift @@ -0,0 +1,169 @@ +// +// VideoFileHelper.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 24/01/2025. +// + +import Foundation +import NitroModules + +// MARK: - LibraryError +enum LibraryError: VideoError { + case deallocated(objectName: String) + case DRMPluginNotFound + + var code: String { + switch self { + case .deallocated: + return "library/deallocated" + case .DRMPluginNotFound: + return "library/drm-plugin-not-found" + } + } + + var message: String { + switch self { + case let .deallocated(objectName: objectName): + return "Object \(objectName) has been deallocated" + case .DRMPluginNotFound: + return "No DRM plugin have been found, please add one to the project" + } + } +} + +// MARK: - PlayerError +enum PlayerError: VideoError { + case notInitialized + case assetNotInitialized + case invalidSource + case invalidTrackUrl(url: String) + case cancelled + + var code: String { + switch self { + case .notInitialized: + return "player/not-initialized" + case .assetNotInitialized: + return "player/asset-not-initialized" + case .invalidSource: + return "player/invalid-source" + case .invalidTrackUrl: + return "player/invalid-track-url" + case .cancelled: + return "player/cancelled" + } + } + + var message: String { + switch self { + case .notInitialized: + return "Player has not been initialized (Or has been set to nil)" + case .assetNotInitialized: + return "Asset has not been initialized (Or has been set to nil)" + case .invalidSource: + return "Invalid source passed to player" + case let .invalidTrackUrl(url: url): + return "Invalid track URL: \(url)" + case .cancelled: + return "Operation was cancelled" + } + } +} + +// MARK: - SourceError +enum SourceError: VideoError { + case invalidUri(uri: String) + case missingReadFilePermission(uri: String) + case fileDoesNotExist(uri: String) + case failedToInitializeAsset + case unsupportedContentType(uri: String) + case cancelled + + var code: String { + switch self { + case .invalidUri: + return "source/invalid-uri" + case .missingReadFilePermission: + return "source/missing-read-file-permission" + case .fileDoesNotExist: + return "source/file-does-not-exist" + case .failedToInitializeAsset: + return "source/failed-to-initialize-asset" + case .unsupportedContentType: + return "source/unsupported-content-type" + case .cancelled: + return "source/cancelled" + } + } + + var message: String { + switch self { + case let .invalidUri(uri: uri): + return "Invalid source file uri: \(uri)" + case let .missingReadFilePermission(uri: uri): + return "Missing read file permission for source file at \(uri)" + case let .fileDoesNotExist(uri: uri): + return "File does not exist at URI: \(uri)" + case .failedToInitializeAsset: + return "Failed to initialize asset" + case let .unsupportedContentType(uri: uri): + return "type of content (\(uri)) is not supported" + case .cancelled: + return "Operation was cancelled" + } + } +} + +// MARK: - VideoViewError +enum VideoViewError: VideoError { + case viewNotFound(nitroId: Double) + case viewIsDeallocated + case pictureInPictureNotSupported + + var code: String { + switch self { + case .viewNotFound: + return "view/not-found" + case .viewIsDeallocated: + return "view/deallocated" + case .pictureInPictureNotSupported: + return "view/picture-in-picture-not-supported" + } + } + + var message: String { + switch self { + case let .viewNotFound(nitroId: nitroId): + return "View with nitroId \(nitroId) not found" + case .viewIsDeallocated: + return "Attempt to access a view, but it has been deallocated (or not initialized)" + case .pictureInPictureNotSupported: + return "Picture in picture is not supported on this device" + } + } +} + +// MARK: - UnknownError +struct UnknownError: VideoError { + var code: String { "unknown/unknown" } + var message: String { "Unknown error" } +} + +// MARK: - VideoError +protocol VideoError { + var code: String { get } + var message: String { get } +} + +extension VideoError { + private func getMessage() -> String { + return "{%@\(code)::\(message)@%}" + } + + func error() -> Error { + return RuntimeError.error(withMessage: getMessage()) + } +} + + diff --git a/ios/core/VideoFileHelper.swift b/ios/core/VideoFileHelper.swift new file mode 100644 index 00000000..3f656e88 --- /dev/null +++ b/ios/core/VideoFileHelper.swift @@ -0,0 +1,48 @@ +// +// VideoFileHelper.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 23/01/2025. +// + +import Foundation +import NitroModules + +enum VideoFileHelper { + private static let fileManager = FileManager.default + + static func getFileSize(for url: URL) async throws -> Int64 { + if url.isFileURL { + return try getLocalFileSize(for: url) + } + return try await getRemoteFileSize(for: url) + } + + static func validateReadPermission(for url: URL) throws { + guard url.isFileURL else { return } + + if !fileManager.isReadableFile(atPath: url.path) { + throw SourceError.missingReadFilePermission(uri: url.path).error() + } + } + + // MARK: - Private + + private static func getLocalFileSize(for url: URL) throws -> Int64 { + Int64(try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? -1) + } + + private static func getRemoteFileSize(for url: URL) async throws -> Int64 { + var request = URLRequest(url: url) + request.httpMethod = "HEAD" + + let (_, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, + let contentLength = httpResponse.allHeaderFields["Content-Length"] as? String, + let size = Int64(contentLength) else { + return -1 + } + + return size + } +} diff --git a/ios/core/VideoManager.swift b/ios/core/VideoManager.swift new file mode 100644 index 00000000..bc2869d6 --- /dev/null +++ b/ios/core/VideoManager.swift @@ -0,0 +1,414 @@ +// +// VideoManager.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 27/04/2025. +// + +import Foundation +import AVFoundation + +class VideoManager { + // MARK: - Singleton + static let shared = VideoManager() + + private var players = NSHashTable.weakObjects() + private var videoView = NSHashTable.weakObjects() + + private var isAudioSessionActive = false + private var remoteControlEventsActive = false + + // TODO: Create Global Config, and expose it there + private var isAudioSessionManagementDisabled: Bool = false + + private init() { + // Subscribe to audio interruption notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAudioSessionInterruption), + name: AVAudioSession.interruptionNotification, + object: nil + ) + + // Subscribe to route change notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAudioRouteChange), + name: AVAudioSession.routeChangeNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillResignActive(notification:)), + name: UIApplication.willResignActiveNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive(notification:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidEnterBackground(notification:)), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillEnterForeground(notification:)), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - public + + func register(player: HybridVideoPlayer) { + players.add(player) + PluginsRegistry.shared.notifyPlayerCreated(player: player) + } + + func unregister(player: HybridVideoPlayer) { + players.remove(player) + PluginsRegistry.shared.notifyPlayerDestroyed(player: player) + } + + func register(view: VideoComponentView) { + videoView.add(view) + } + + func unregister(view: VideoComponentView) { + videoView.remove(view) + } + + func requestAudioSessionUpdate() { + updateAudioSessionConfiguration() + } + + // MARK: - Remote Control Events + func setRemoteControlEventsActive(_ active: Bool) { + if isAudioSessionManagementDisabled || remoteControlEventsActive == active { + return + } + + remoteControlEventsActive = active + requestAudioSessionUpdate() + } + + // MARK: - Audio Session Management + private func activateAudioSession() { + if isAudioSessionActive { + return + } + + do { + try AVAudioSession.sharedInstance().setActive(true) + isAudioSessionActive = true + } catch { + print("Failed to activate audio session: \(error.localizedDescription)") + } + } + + private func deactivateAudioSession() { + if !isAudioSessionActive { + return + } + + do { + try AVAudioSession.sharedInstance().setActive( + false, options: .notifyOthersOnDeactivation + ) + isAudioSessionActive = false + } catch { + print("Failed to deactivate audio session: \(error.localizedDescription)") + } + } + + private func updateAudioSessionConfiguration() { + let isAnyPlayerPlaying = players.allObjects.contains { hybridPlayer in + hybridPlayer.player.isMuted == false && hybridPlayer.player.rate != 0 + } + + let anyPlayerNeedsNotMixWithOthers = players.allObjects.contains { player in + player.mixAudioMode == .donotmix + } + + let anyPlayerNeedsNotificationControls = players.allObjects.contains { player in + player.showNotificationControls + } + + if isAnyPlayerPlaying || anyPlayerNeedsNotMixWithOthers || anyPlayerNeedsNotificationControls || remoteControlEventsActive { + activateAudioSession() + } else { + deactivateAudioSession() + } + + configureAudioSession() + } + + private func configureAudioSession() { + let audioSession = AVAudioSession.sharedInstance() + var audioSessionCategoryOptions: AVAudioSession.CategoryOptions = audioSession.categoryOptions + + let anyViewNeedsPictureInPicture = videoView.allObjects.contains { view in + view.allowsPictureInPicturePlayback + } + + let anyPlayerNeedsSilentSwitchObey = players.allObjects.contains { player in + player.ignoreSilentSwitchMode == .obey + } + + let anyPlayerNeedsSilentSwitchIgnore = players.allObjects.contains { player in + player.ignoreSilentSwitchMode == .ignore + } + + let anyPlayerNeedsBackgroundPlayback = players.allObjects.contains { player in + player.playInBackground + } + + let anyPlayerNeedsNotificationControls = players.allObjects.contains { player in + player.showNotificationControls + } + + if isAudioSessionManagementDisabled { + return + } + + let category: AVAudioSession.Category = determineAudioCategory( + silentSwitchObey: anyPlayerNeedsSilentSwitchObey, + silentSwitchIgnore: anyPlayerNeedsSilentSwitchIgnore, + earpiece: false, // TODO: Pass actual value after we add prop + pip: anyViewNeedsPictureInPicture, + backgroundPlayback: anyPlayerNeedsBackgroundPlayback, + notificationControls: anyPlayerNeedsNotificationControls + ) + + let audioMixingMode = determineAudioMixingMode() + + + audioSessionCategoryOptions.remove(.duckOthers) + audioSessionCategoryOptions.remove(.mixWithOthers) + + switch audioMixingMode { + case .mixwithothers: + audioSessionCategoryOptions.insert(.mixWithOthers) + case .duckothers: + audioSessionCategoryOptions.insert(.duckOthers) + case .auto, .donotmix: + // Do nothing as we already cleared the options + break + } + + do { + try audioSession.setCategory(category, mode: .moviePlayback, options: audioSessionCategoryOptions) + } catch { + print("ReactNativeVideo: Failed to set audio session category: \(error.localizedDescription)") + } + } + + private func determineAudioCategory( + silentSwitchObey: Bool, + silentSwitchIgnore: Bool, + earpiece: Bool, + pip: Bool, + backgroundPlayback: Bool, + notificationControls: Bool + ) -> AVAudioSession.Category { + // Handle conflicting settings + if silentSwitchObey && silentSwitchIgnore { + print( + "Warning: Conflicting ignoreSilentSwitch settings found (obey vs ignore) - defaulting to ignore" + ) + return .playback + } + + // PiP, background playback, or notification controls require playback category + if pip || backgroundPlayback || notificationControls || remoteControlEventsActive { + if silentSwitchObey { + print( + "Warning: ignoreSilentSwitch=obey cannot be used with PiP, backgroundPlayback, or notification controls - using playback category" + ) + } + + if earpiece { + print( + "Warning: audioOutput=earpiece cannot be used with PiP, backgroundPlayback, or notification controls - using playback category" + ) + } + + // Set up background playback policy if needed + if backgroundPlayback { + players.allObjects.forEach { player in + if player.playInBackground { + player.player.audiovisualBackgroundPlaybackPolicy = .continuesIfPossible + } else { + player.player.audiovisualBackgroundPlaybackPolicy = .pauses + } + } + } + + return .playback + } + + // Earpiece requires playAndRecord + if earpiece { + if silentSwitchObey { + print( + "Warning: audioOutput=earpiece cannot be used with ignoreSilentSwitch=obey - using playAndRecord category" + ) + } + return .playAndRecord + } + + // Honor silent switch if requested + if silentSwitchObey { + return .ambient + } + + // Default to playback for most cases + return .playback + } + + func determineAudioMixingMode() -> MixAudioMode { + let activePlayers = players.allObjects.filter { player in + player.isPlaying && player.player.isMuted != true + } + + if activePlayers.isEmpty { + return .mixwithothers + } + + let anyPlayerNeedsMixWithOthers = activePlayers.contains { player in + player.mixAudioMode == .mixwithothers + } + + let anyPlayerNeedsNotMixWithOthers = activePlayers.contains { player in + player.mixAudioMode == .donotmix + } + + let anyPlayerNeedsDucksOthers = activePlayers.contains { player in + player.mixAudioMode == .duckothers + } + + let anyPlayerHasAutoMixAudioMode = activePlayers.contains { player in + player.mixAudioMode == .auto + } + + if anyPlayerNeedsNotMixWithOthers { + return .donotmix + } + + if anyPlayerHasAutoMixAudioMode { + return .auto + } + + if anyPlayerNeedsDucksOthers { + return .duckothers + } + + return .mixwithothers + } + + + // MARK: - Notification Handlers + + @objc + private func handleAudioSessionInterruption(notification: Notification) { + guard let userInfo = notification.userInfo, + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { + return + } + + switch type { + case .began: + // Audio session interrupted, nothing to do as players will pause automatically + break + + case .ended: + // Interruption ended, check if we should resume audio session + if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { + let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) + if options.contains(.shouldResume) { + updateAudioSessionConfiguration() + } + } + + @unknown default: + break + } + } + + @objc + private func handleAudioRouteChange(notification: Notification) { + guard let userInfo = notification.userInfo, + let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) + else { + return + } + + switch reason { + case .categoryChange, .override, .wakeFromSleep, .newDeviceAvailable, .oldDeviceUnavailable: + // Reconfigure audio session when route changes + updateAudioSessionConfiguration() + default: + break + } + } + + @objc func applicationWillResignActive(notification: Notification) { + // Pause all players when the app is about to become inactive + for player in players.allObjects { + if player.playInBackground || player.playWhenInactive || !player.isPlaying || player.player.isExternalPlaybackActive == true { + continue + } + + try? player.pause() + player.wasAutoPaused = true + } + } + + @objc func applicationDidBecomeActive(notification: Notification) { + // Resume all players when the app becomes active + for player in players.allObjects { + if player.wasAutoPaused { + try? player.play() + player.wasAutoPaused = false + } + } + } + + @objc func applicationDidEnterBackground(notification: Notification) { + // Pause all players when the app enters background + for player in players.allObjects { + if player.playInBackground || player.player.isExternalPlaybackActive == true || !player.isPlaying { + continue + } + + try? player.pause() + player.wasAutoPaused = true + } + } + + @objc func applicationWillEnterForeground(notification: Notification) { + // Resume all players when the app enters foreground + for player in players.allObjects { + if player.wasAutoPaused { + try? player.play() + player.wasAutoPaused = false + } + } + } +} diff --git a/ios/core/VideoPlayerObserver.swift b/ios/core/VideoPlayerObserver.swift new file mode 100644 index 00000000..b6f65e45 --- /dev/null +++ b/ios/core/VideoPlayerObserver.swift @@ -0,0 +1,294 @@ +// +// VideoPlayerObserver.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 15/04/2025. +// + +import Foundation +import AVFoundation + +protocol VideoPlayerObserverDelegate: AnyObject { + func onPlayedToEnd(player: AVPlayer) + func onPlayerItemChange(player: AVPlayer, playerItem: AVPlayerItem?) + func onPlayerItemWillChange(hasNewPlayerItem: Bool) + func onTextTrackDataChanged(texts: [NSAttributedString]) + func onTimedMetadataChanged(timedMetadata: [AVMetadataItem]) + func onRateChanged(rate: Float) + func onPlaybackBufferEmpty() + func onPlaybackLikelyToKeepUp() + func onVolumeChanged(volume: Float) + func onExternalPlaybackActiveChanged(isActive: Bool) + func onTimeControlStatusChanged(status: AVPlayer.TimeControlStatus) + func onPlayerStatusChanged(status: AVPlayer.Status) + func onPlayerItemStatusChanged(status: AVPlayerItem.Status) + func onBandwidthUpdate(bitrate: Double) + func onProgressUpdate(currentTime: Double, bufferDuration: Double) +} + +extension VideoPlayerObserverDelegate { + func onPlayedToEnd(player: AVPlayer) {} + func onPlayerItemChange(player: AVPlayer, playerItem: AVPlayerItem?) {} + func onPlayerItemWillChange(hasNewPlayerItem: Bool) {} + func onTextTrackDataChanged(texts: [NSAttributedString]) {} + func onTimedMetadataChanged(timedMetadata: [AVMetadataItem]) {} + func onRateChanged(rate: Float) {} + func onPlaybackBufferEmpty() {} + func onPlaybackLikelyToKeepUp() {} + func onVolumeChanged(volume: Float) {} + func onExternalPlaybackActiveChanged(isActive: Bool) {} + func onTimeControlStatusChanged(status: AVPlayer.TimeControlStatus) {} + func onPlayerStatusChanged(status: AVPlayer.Status) {} + func onPlayerItemStatusChanged(status: AVPlayerItem.Status) {} + func onBandwidthUpdate(bitrate: Double) {} + func onProgressUpdate(currentTime: Double, bufferDuration: Double) {} +} + +class VideoPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVPlayerItemLegibleOutputPushDelegate { + private weak var delegate: HybridVideoPlayer? + var player: AVPlayer? { + delegate?.player + } + + // Player observers + var playerCurrentItemObserver: NSKeyValueObservation? + var playerRateObserver: NSKeyValueObservation? + var playerTimeControlStatusObserver: NSKeyValueObservation? + var playerExternalPlaybackActiveObserver: NSKeyValueObservation? + var playerVolumeObserver: NSKeyValueObservation? + var playerTimedMetadataObserver: NSKeyValueObservation? + var playerStatusObserver: NSKeyValueObservation? + var playerProgressPeriodicObserver: Any? + + // Player item observers + var playbackEndedObserver: NSObjectProtocol? + var playbackBufferEmptyObserver: NSKeyValueObservation? + var playbackLikelyToKeepUpObserver: NSKeyValueObservation? + var playbackBufferFullObserver: NSKeyValueObservation? + var playerItemStatusObserver: NSKeyValueObservation? + var playerItemAccessLogObserver: NSObjectProtocol? + + var metadataOutput: AVPlayerItemMetadataOutput? + var legibleOutput: AVPlayerItemLegibleOutput? + + init(delegate: HybridVideoPlayer) { + self.delegate = delegate + } + + deinit { + invalidatePlayerObservers() + invalidatePlayerItemObservers() + } + + public func updatePlayerObservers() { + invalidatePlayerItemObservers() + invalidatePlayerObservers() + + initializePlayerObservers() + } + + func initializePlayerObservers() { + guard let player else { + return + } + + playerCurrentItemObserver = player.observe(\.currentItem, options: [.new, .old]) { [weak self] _, change in + self?.onPlayerCurrentItemChanged(player: player, change: change) + } + + playerRateObserver = player.observe(\.rate, options: [.new]) { [weak self] _, change in + guard let rate = change.newValue else { return } + self?.delegate?.onRateChanged(rate: rate) + } + + playerTimeControlStatusObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, change in + guard let status = change.newValue else { return } + self?.delegate?.onTimeControlStatusChanged(status: status) + } + + playerExternalPlaybackActiveObserver = player.observe(\.isExternalPlaybackActive, options: [.new]) { [weak self] _, change in + guard let isActive = change.newValue else { return } + self?.delegate?.onExternalPlaybackActiveChanged(isActive: isActive) + } + + playerVolumeObserver = player.observe(\.volume, options: [.new]) { [weak self] _, change in + guard let volume = change.newValue else { return } + self?.delegate?.onVolumeChanged(volume: volume) + } + + playerStatusObserver = player.observe(\.status, options: [.new]) { [weak self] _, change in + guard let status = change.newValue else { return } + self?.delegate?.onPlayerStatusChanged(status: status) + } + + // 500ms interval TODO: Make this configurable + let interval = CMTime(seconds: 0.5, preferredTimescale: 600) + playerProgressPeriodicObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] _ in + guard let self, let player = self.player, let delegate = self.delegate else { return } + + delegate.onProgressUpdate(currentTime: player.currentTime().seconds, bufferDuration: player.currentItem?.getbufferDuration() ?? 0) + } + } + + private func initializePlayerItemObservers(player: AVPlayer, playerItem: AVPlayerItem) { + playbackEndedObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: playerItem, + queue: nil + ) { [weak self] notification in + self?.delegate?.onPlayedToEnd(player: player) + } + + playerItemAccessLogObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemNewAccessLogEntry, + object: playerItem, + queue: nil + ) { [weak self] notification in + self?.onPlayerAccessLog(playerItem: playerItem) + } + + setupBufferObservers(for: playerItem) + + playerItemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] _, change in + self?.delegate?.onPlayerItemStatusChanged(status: playerItem.status) + } + + let metadataOutput = AVPlayerItemMetadataOutput() + playerItem.add(metadataOutput) + metadataOutput.setDelegate(self, queue: .global(qos: .userInteractive)) + + let legibleOutput = AVPlayerItemLegibleOutput() + playerItem.add(legibleOutput) + metadataOutput.setDelegate(self, queue: .global(qos: .userInteractive)) + } + + func invalidatePlayerItemObservers() { + // Remove NotificationCenter observers + if let playbackEndedObserver = playbackEndedObserver { + NotificationCenter.default.removeObserver(playbackEndedObserver) + self.playbackEndedObserver = nil + } + if let playerItemAccessLogObserver = playerItemAccessLogObserver { + NotificationCenter.default.removeObserver(playerItemAccessLogObserver) + self.playerItemAccessLogObserver = nil + } + // Invalidate KVO observers + clearBufferObservers() + playerItemStatusObserver?.invalidate() + playerItemStatusObserver = nil + // Remove outputs if needed + // (Assumes outputs are not shared between items) + if let playerItem = player?.currentItem { + if let metadataOutput = metadataOutput { + playerItem.remove(metadataOutput) + } + if let legibleOutput = legibleOutput { + playerItem.remove(legibleOutput) + } + } + metadataOutput = nil + legibleOutput = nil + } + + func invalidatePlayerObservers() { + // Invalidate KVO observers + playerCurrentItemObserver?.invalidate() + playerCurrentItemObserver = nil + playerRateObserver?.invalidate() + playerRateObserver = nil + playerTimeControlStatusObserver?.invalidate() + playerTimeControlStatusObserver = nil + playerExternalPlaybackActiveObserver?.invalidate() + playerExternalPlaybackActiveObserver = nil + playerVolumeObserver?.invalidate() + playerVolumeObserver = nil + playerStatusObserver?.invalidate() + playerStatusObserver = nil + // Remove periodic time observer from player + if let player = player, let periodicObserver = playerProgressPeriodicObserver { + player.removeTimeObserver(periodicObserver) + playerProgressPeriodicObserver = nil + } + } + + // MARK: - AVPlayerItemLegibleOutputPushDelegate + public func legibleOutput(_: AVPlayerItemLegibleOutput, + didOutputAttributedStrings strings: [NSAttributedString], + nativeSampleBuffers _: [Any], + forItemTime _: CMTime) { + delegate?.onTextTrackDataChanged(texts: strings) + } + + // MARK: - AVPlayerItemMetadataOutputPushDelegate + public func metadataOutput(_: AVPlayerItemMetadataOutput, didOutputTimedMetadataGroups groups: [AVTimedMetadataGroup], from _: AVPlayerItemTrack?) { + for metadataGroup in groups { + delegate?.onTimedMetadataChanged(timedMetadata: metadataGroup.items) + } + } + + // MARK: - AVPlayer Observers + func onPlayerCurrentItemChanged(player: AVPlayer, change: NSKeyValueObservedChange) { + let newPlayerItem = change.newValue?.flatMap { $0 } + + // Remove observers for old player item + invalidatePlayerItemObservers() + + // Notify delegate about player item state change + delegate?.onPlayerItemWillChange(hasNewPlayerItem: newPlayerItem != nil) + + if let playerItem = newPlayerItem { + // Initialize observers for new player item + initializePlayerItemObservers(player: player, playerItem: playerItem) + + delegate?.onPlayerItemChange(player: player, playerItem: playerItem) + } + } + + // MARK: - AVPlayerItem Observers + func onPlayerAccessLog(playerItem: AVPlayerItem) { + guard let accessLog = playerItem.accessLog() else { return } + guard let lastEvent = accessLog.events.last else { return } + + delegate?.onBandwidthUpdate(bitrate: lastEvent.indicatedBitrate) + } + + // MARK: - Buffer State Management + + func setupBufferObservers(for playerItem: AVPlayerItem) { + clearBufferObservers() + + // Observe buffer empty - this indicates definite buffering + playbackBufferEmptyObserver = playerItem.observe(\.isPlaybackBufferEmpty, options: [.new, .initial]) { [weak self] playerItem, change in + let isEmpty = change.newValue ?? playerItem.isPlaybackBufferEmpty + if isEmpty { + self?.delegate?.onPlaybackBufferEmpty() + } + } + + // Observe likely to keep up - this indicates that buffering has finished + playbackLikelyToKeepUpObserver = playerItem.observe(\.isPlaybackLikelyToKeepUp, options: [.new, .initial]) { [weak self] playerItem, change in + let isLikelyToKeepUp = change.newValue ?? playerItem.isPlaybackLikelyToKeepUp + if isLikelyToKeepUp { + self?.delegate?.onPlaybackLikelyToKeepUp() + } + } + + // Observe buffer full as an additional signal + playbackBufferFullObserver = playerItem.observe(\.isPlaybackBufferFull, options: [.new, .initial]) { [weak self] playerItem, change in + let isFull = change.newValue ?? playerItem.isPlaybackBufferFull + if isFull { + self?.delegate?.onPlaybackLikelyToKeepUp() + } + } + } + + func clearBufferObservers() { + playbackBufferEmptyObserver?.invalidate() + playbackBufferFullObserver?.invalidate() + playbackLikelyToKeepUpObserver?.invalidate() + + playbackBufferEmptyObserver = nil + playbackBufferFullObserver = nil + playbackLikelyToKeepUpObserver = nil + } +} diff --git a/ios/hybrids/VideoPlayer/HybridVideoPlayer+Events.swift b/ios/hybrids/VideoPlayer/HybridVideoPlayer+Events.swift new file mode 100644 index 00000000..986d20f2 --- /dev/null +++ b/ios/hybrids/VideoPlayer/HybridVideoPlayer+Events.swift @@ -0,0 +1,205 @@ +// +// HybridVideoPlayer+Events.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 02/05/2025. +// + +import AVFoundation +import Foundation + +extension HybridVideoPlayer: VideoPlayerObserverDelegate { + // MARK: - VideoPlayerObserverDelegate + + func onPlayedToEnd(player: AVPlayer) { + _eventEmitter?.onEnd() + + if loop { + currentTime = 0 + try? play() + } + } + + func onRateChanged(rate: Float) { + _eventEmitter?.onPlaybackRateChange(Double(rate)) + NowPlayingInfoCenterManager.shared.updatePlaybackState() + updateAndEmitPlaybackState() + } + + func onVolumeChanged(volume: Float) { + _eventEmitter?.onVolumeChange( + onVolumeChangeData( + volume: Double(volume), + muted: muted + ) + ) + } + + func onPlaybackBufferEmpty() { + isCurrentlyBuffering = true + status = .loading + updateAndEmitPlaybackState() + } + + func onProgressUpdate(currentTime: Double, bufferDuration: Double) { + _eventEmitter?.onProgress( + .init(currentTime: currentTime, bufferDuration: bufferDuration) + ) + } + + func onPlaybackLikelyToKeepUp() { + isCurrentlyBuffering = false + if player.timeControlStatus != .waitingToPlayAtSpecifiedRate { + status = .readytoplay + } + updateAndEmitPlaybackState() + } + + func onExternalPlaybackActiveChanged(isActive: Bool) { + _eventEmitter?.onExternalPlaybackChange(isActive) + } + + func onTimeControlStatusChanged(status: AVPlayer.TimeControlStatus) { + if player.status == .failed || playerItem?.status == .failed { + self.status = .error + isCurrentlyBuffering = false + _eventEmitter?.onPlaybackStateChange( + .init(isPlaying: false, isBuffering: false) + ) + return + } + + switch status { + case .waitingToPlayAtSpecifiedRate: + isCurrentlyBuffering = true + self.status = .loading + break + + case .playing: + isCurrentlyBuffering = false + self.status = .readytoplay + break + + case .paused: + isCurrentlyBuffering = false + self.status = .readytoplay + break + + @unknown default: + break + } + + updateAndEmitPlaybackState() + } + + func onPlayerStatusChanged(status: AVPlayer.Status) { + if status == .failed || playerItem?.status == .failed { + self.status = .error + isCurrentlyBuffering = false + updateAndEmitPlaybackState() + } + } + + func onPlayerItemStatusChanged(status: AVPlayerItem.Status) { + if status == .failed { + self.status = .error + isCurrentlyBuffering = false + updateAndEmitPlaybackState() + return + } + + switch status { + case .unknown: + isCurrentlyBuffering = true + self.status = .loading + + // Set initial buffering state when we have a playerItem + if let playerItem = self.playerItem { + if playerItem.isPlaybackBufferEmpty { + isCurrentlyBuffering = true + } + } + + case .readyToPlay: + guard let playerItem else { return } + + let height = playerItem.presentationSize.height + let width = playerItem.presentationSize.width + let orientation: VideoOrientation = + playerItem.asset.tracks.first(where: { $0.mediaType == .video })? + .orientation ?? .unknown + + _eventEmitter?.onLoad( + .init(currentTime, duration, height, width, orientation) + ) + + if playerItem.isPlaybackLikelyToKeepUp + && !playerItem.isPlaybackBufferEmpty + { + isCurrentlyBuffering = false + self.status = .readytoplay + } + + case .failed: + self.status = .error + isCurrentlyBuffering = false + + @unknown default: + break + } + + updateAndEmitPlaybackState() + } + + func onTextTrackDataChanged(texts: [NSAttributedString]) { + _eventEmitter?.onTextTrackDataChanged(texts.map { $0.string }) + } + + func onTimedMetadataChanged(timedMetadata: [AVMetadataItem]) { + var metadata: [TimedMetadataObject] = [] + for item in timedMetadata { + let value = item.value as? String + let identifier = item.identifier?.rawValue + + if let value, let identifier { + metadata.append(.init(value: value, identifier: identifier)) + } + } + + _eventEmitter?.onTimedMetadata(.init(metadata: metadata)) + } + + func onBandwidthUpdate(bitrate: Double) { + _eventEmitter?.onBandwidthUpdate( + .init(bitrate: bitrate, width: nil, height: nil) + ) + } + + func onPlayerItemChange(player _: AVPlayer, playerItem: AVPlayerItem?) { + guard showNotificationControls, let playerItem else { return } + DispatchQueue.main.async { + NowPlayingInfoCenterManager.shared.updateStaticInfo(ifCurrentItem: playerItem) + } + } + + func onPlayerItemWillChange(hasNewPlayerItem: Bool) { + if hasNewPlayerItem { + // Set initial buffering state when playerItem is assigned + isCurrentlyBuffering = true + status = .loading + updateAndEmitPlaybackState() + } else { + // Clean up state when playerItem is cleared + isCurrentlyBuffering = false + } + } + + func updateAndEmitPlaybackState() { + let isPlaying = player.rate > 0 && !isCurrentlyBuffering + + _eventEmitter?.onPlaybackStateChange( + .init(isPlaying: isPlaying, isBuffering: isCurrentlyBuffering) + ) + _eventEmitter?.onBuffer(isCurrentlyBuffering) + } +} diff --git a/ios/hybrids/VideoPlayer/HybridVideoPlayer.swift b/ios/hybrids/VideoPlayer/HybridVideoPlayer.swift new file mode 100644 index 00000000..433ebdb7 --- /dev/null +++ b/ios/hybrids/VideoPlayer/HybridVideoPlayer.swift @@ -0,0 +1,599 @@ +// +// HybridVideoPlayer.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 09/10/2024. +// + +import AVFoundation +import Foundation +import NitroModules + +class HybridVideoPlayer: HybridVideoPlayerSpec, NativeVideoPlayerSpec { + + /** + * Player instance for video playback + */ + var player: AVPlayer { + didSet { + playerObserver?.initializePlayerObservers() + } + willSet { + playerObserver?.invalidatePlayerObservers() + } + } + + var playerItem: AVPlayerItem? { + didSet { + if let bufferConfig = source.config.bufferConfig { + playerItem?.setBufferConfig(config: bufferConfig) + } + } + } + var playerObserver: VideoPlayerObserver? + private let sourceLoader = SourceLoader() + + init(source: (any HybridVideoPlayerSourceSpec)) throws { + self.source = source + self.eventEmitter = HybridVideoPlayerEventEmitter() + + // Initialize AVPlayer with empty item + self.player = AVPlayer() + + super.init() + self.playerObserver = VideoPlayerObserver(delegate: self) + self.playerObserver?.initializePlayerObservers() + + Task { + if source.config.initializeOnCreation == true { + do { + self.playerItem = try await self.sourceLoader.load { + try await self.initializePlayerItem() + } + self.player.replaceCurrentItem(with: self.playerItem) + } catch { + // Ignore cancellation errors during initialization + } + } + } + + VideoManager.shared.register(player: self) + } + + deinit { + release() + } + + // MARK: - Hybrid Impl + + var source: any HybridVideoPlayerSourceSpec + + var status: VideoPlayerStatus = .idle { + didSet { + if status != oldValue { + _eventEmitter?.onStatusChange(status) + } + } + } + + var eventEmitter: HybridVideoPlayerEventEmitterSpec + var _eventEmitter: HybridVideoPlayerEventEmitter? { + return eventEmitter as? HybridVideoPlayerEventEmitter + } + + var volume: Double { + set { + player.volume = Float(newValue) + } + get { + return Double(player.volume) + } + } + + var muted: Bool { + set { + player.isMuted = newValue + _eventEmitter?.onVolumeChange( + onVolumeChangeData( + volume: Double(player.volume), + muted: muted + ) + ) + } + get { + return player.isMuted + } + } + + var currentTime: Double { + set { + _eventEmitter?.onSeek(newValue) + player.seek( + to: CMTime(seconds: newValue, preferredTimescale: 1000), + toleranceBefore: .zero, + toleranceAfter: .zero + ) + } + get { + player.currentTime().seconds + } + } + + var duration: Double { + Double(player.currentItem?.duration.seconds ?? Double.nan) + } + + var rate: Double { + set { + if #available(iOS 16.0, tvOS 16.0, *) { + player.defaultRate = Float(newValue) + } + + player.rate = Float(newValue) + } + get { + return Double(player.rate) + } + } + + var loop: Bool = false + + var mixAudioMode: MixAudioMode = .auto { + didSet { + VideoManager.shared.requestAudioSessionUpdate() + } + } + + var ignoreSilentSwitchMode: IgnoreSilentSwitchMode = .auto { + didSet { + VideoManager.shared.requestAudioSessionUpdate() + } + } + + var playInBackground: Bool = false { + didSet { + VideoManager.shared.requestAudioSessionUpdate() + } + } + + var playWhenInactive: Bool = false + + var wasAutoPaused: Bool = false + + // Text track selection state + private var selectedExternalTrackIndex: Int? = nil + + var isCurrentlyBuffering: Bool = false + + var isPlaying: Bool { + return player.rate != 0 + } + + var showNotificationControls: Bool = false { + didSet { + if showNotificationControls { + NowPlayingInfoCenterManager.shared.registerPlayer(player: player) + } else { + NowPlayingInfoCenterManager.shared.removePlayer(player: player) + } + } + } + + func initialize() throws -> Promise { + return Promise.async { [weak self] in + guard let self else { + throw LibraryError.deallocated(objectName: "HybridVideoPlayer").error() + } + + if self.playerItem != nil { + return + } + + do { + self.playerItem = try await self.sourceLoader.load { + try await self.initializePlayerItem() + } + self.player.replaceCurrentItem(with: self.playerItem) + } catch { + if error is CancellationError { + throw PlayerError.cancelled.error() + } + throw error + } + } + } + + func release() { + sourceLoader.cancelSync() + NowPlayingInfoCenterManager.shared.removePlayer(player: player) + + try? _eventEmitter?.clearAllListeners() + + self.playerItem = nil + + if let source = self.source as? HybridVideoPlayerSource { + source.releaseAsset() + } + + // Clear player observer + playerObserver?.invalidatePlayerItemObservers() + playerObserver?.invalidatePlayerObservers() + self.playerObserver = nil + + self.player.replaceCurrentItem(with: nil) + status = .idle + + VideoManager.shared.unregister(player: self) + } + + func preload() throws -> NitroModules.Promise { + let promise = Promise() + + if status != .idle { + promise.resolve(withResult: ()) + return promise + } + + Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { + promise.reject( + withError: LibraryError.deallocated(objectName: "HybridVideoPlayer") + .error() + ) + return + } + + do { + let playerItem = try await self.sourceLoader.load { + try await self.initializePlayerItem() + } + self.playerItem = playerItem + + self.player.replaceCurrentItem(with: playerItem) + promise.resolve(withResult: ()) + } catch { + if error is CancellationError { + promise.reject(withError: PlayerError.cancelled.error()) + } else { + promise.reject(withError: error) + } + } + } + + return promise + } + + func play() throws { + player.play() + } + + func pause() throws { + player.pause() + } + + func seekBy(time: Double) throws { + guard let currentItem = player.currentItem else { + throw PlayerError.notInitialized.error() + } + + let currentItemTime = currentItem.currentTime() + + // Duration is NaN for live streams + let fixedDurration = duration.isNaN ? Double.infinity : duration + + // Clap by <0, duration> + let newTime = max(0, min(currentItemTime.seconds + time, fixedDurration)) + + currentTime = newTime + } + + func seekTo(time: Double) { + currentTime = time + } + + func replaceSourceAsync( + source: Variant_NullType__any_HybridVideoPlayerSourceSpec_? + ) throws + -> Promise + { + let promise = Promise() + + /** + @frozen + public indirect enum Variant_NullType__any_HybridVideoPlayerSourceSpec_ { + case first(NullType) + case second((any HybridVideoPlayerSourceSpec)) + } + */ + + // if source is nil, release player + // if source is not NullType, set source + guard let source else { + release() + promise.resolve(withResult: ()) + return promise + } + + switch source { + case .first(_): + release() + promise.resolve(withResult: ()) + return promise + case .second(let newSource): + Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { + promise.reject( + withError: LibraryError.deallocated(objectName: "HybridVideoPlayer") + .error() + ) + return + } + + await self.sourceLoader.cancel() + + if let oldSource = self.source as? HybridVideoPlayerSource { + oldSource.releaseAsset() + } + + self.source = newSource + + do { + self.playerItem = try await self.sourceLoader.load { + try await self.initializePlayerItem() + } + self.player.replaceCurrentItem(with: self.playerItem) + promise.resolve(withResult: ()) + } catch { + if error is CancellationError { + promise.reject(withError: PlayerError.cancelled.error()) + } else { + promise.reject(withError: error) + } + } + } + } + + return promise + } + + // MARK: - Methods + + func initializePlayerItem() async throws -> AVPlayerItem { + // Ensure the source is a valid HybridVideoPlayerSource + guard let _hybridSource = source as? HybridVideoPlayerSource else { + status = .error + throw PlayerError.invalidSource.error() + } + + // (maybe) Override source with plugins + let _source = await PluginsRegistry.shared.overrideSource( + source: _hybridSource + ) + + let isNetworkSource = _source.url.isFileURL == false + _eventEmitter?.onLoadStart( + .init(sourceType: isNetworkSource ? .network : .local, source: _source) + ) + + let asset = try await _source.getAsset() + + let playerItem: AVPlayerItem + + if let externalSubtitles = source.config.externalSubtitles, + externalSubtitles.isEmpty == false + { + playerItem = try await AVPlayerItem.withExternalSubtitles( + for: asset, + config: source.config + ) + } else { + playerItem = AVPlayerItem(asset: asset) + } + + if let metadata = source.config.metadata { + let title = metadata.title + let artist = metadata.artist + let imageUri = metadata.imageUri + + DispatchQueue.main.async { [weak playerItem] in + guard let playerItem else { return } + var items: [AVMetadataItem] = [] + + if let title { + items.append(.make(identifier: .commonIdentifierTitle, value: title as NSString)) + } + if let artist { + items.append(.make(identifier: .commonIdentifierArtist, value: artist as NSString)) + } + if !items.isEmpty { + playerItem.externalMetadata = items + NowPlayingInfoCenterManager.shared.updateStaticInfo(ifCurrentItem: playerItem) + } + } + + // Load artwork in background to not block player initialization + if let imageUri, let imageUrl = URL(string: imageUri) { + Task { [weak playerItem] in + guard let (data, _) = try? await URLSession.shared.data(from: imageUrl) else { + print("[RNV] Failed to load artwork from: \(imageUrl)") + return + } + DispatchQueue.main.async { + guard let playerItem else { return } + playerItem.externalMetadata = playerItem.externalMetadata + [.make(identifier: .commonIdentifierArtwork, value: data as NSData)] + NowPlayingInfoCenterManager.shared.updateStaticInfo(ifCurrentItem: playerItem) + } + } + } else if let imageUri { + print("[RNV] Invalid imageUri for artwork: \(imageUri)") + } + } + + return playerItem + } + + // MARK: - Text Track Management + + func getAvailableTextTracks() throws -> [TextTrack] { + guard let currentItem = player.currentItem else { + return [] + } + + var tracks: [TextTrack] = [] + + if let mediaSelection = currentItem.asset.mediaSelectionGroup( + forMediaCharacteristic: .legible + ) { + for (index, option) in mediaSelection.options.enumerated() { + let isSelected = + currentItem.currentMediaSelection.selectedMediaOption( + in: mediaSelection + ) == option + + let name = + option.commonMetadata.first(where: { $0.commonKey == .commonKeyTitle } + )?.stringValue + ?? option.displayName + + let isExternal = + source.config.externalSubtitles?.contains { subtitle in + name.contains(subtitle.label) + } ?? false + + let trackId = + isExternal + ? "external-\(index)" + : "builtin-\(option.displayName)-\(option.locale?.identifier ?? "unknown")" + + tracks.append( + TextTrack( + id: trackId, + label: option.displayName, + language: option.locale?.identifier, + selected: isSelected + ) + ) + } + } + + return tracks + } + + func selectTextTrack(textTrack: Variant_NullType_TextTrack?) throws { + guard let currentItem = player.currentItem else { + throw PlayerError.notInitialized.error() + } + + guard + let mediaSelection = currentItem.asset.mediaSelectionGroup( + forMediaCharacteristic: .legible + ) + else { + return + } + + // If textTrack is nil, deselect any selected track + guard let textTrack = textTrack else { + currentItem.select(nil, in: mediaSelection) + selectedExternalTrackIndex = nil + _eventEmitter?.onTrackChange(nil) + return + } + + switch textTrack { + case .first(_): + currentItem.select(nil, in: mediaSelection) + selectedExternalTrackIndex = nil + _eventEmitter?.onTrackChange(nil) + return + case .second(let textTrack): + // If textTrack id is empty, deselect any selected track + if textTrack.id.isEmpty { + currentItem.select(nil, in: mediaSelection) + selectedExternalTrackIndex = nil + _eventEmitter?.onTrackChange(nil) + return + } + + if textTrack.id.hasPrefix("external-") { + let trackIndexStr = String(textTrack.id.dropFirst("external-".count)) + if let trackIndex = Int(trackIndexStr), + trackIndex < mediaSelection.options.count + { + let option = mediaSelection.options[trackIndex] + currentItem.select(option, in: mediaSelection) + selectedExternalTrackIndex = trackIndex + _eventEmitter?.onTrackChange(.second(textTrack)) + } + } else if textTrack.id.hasPrefix("builtin-") { + for option in mediaSelection.options { + let optionId = + "builtin-\(option.displayName)-\(option.locale?.identifier ?? "unknown")" + if optionId == textTrack.id { + currentItem.select(option, in: mediaSelection) + selectedExternalTrackIndex = nil + _eventEmitter?.onTrackChange(.second(textTrack)) + return + } + } + } + } + } + + var selectedTrack: TextTrack? { + guard let currentItem = player.currentItem else { + return nil + } + + guard + let mediaSelection = currentItem.asset.mediaSelectionGroup( + forMediaCharacteristic: .legible + ) + else { + return nil + } + + guard + let selectedOption = currentItem.currentMediaSelection + .selectedMediaOption(in: mediaSelection) + else { + return nil + } + + guard let index = mediaSelection.options.firstIndex(of: selectedOption) + else { + return nil + } + + let isExternal = + source.config.externalSubtitles?.contains { subtitle in + selectedOption.displayName.contains(subtitle.label) + } ?? false + + let trackId = + isExternal + ? "external-\(index)" + : "builtin-\(selectedOption.displayName)-\(selectedOption.locale?.identifier ?? "unknown")" + + return TextTrack( + id: trackId, + label: selectedOption.displayName, + language: selectedOption.locale?.identifier, + selected: true + ) + } + + // MARK: - Memory Management + + func dispose() { + release() + } + + var memorySize: Int { + var size = 0 + + size += playerItem?.asset.estimatedMemoryUsage ?? 0 + + return size + } +} diff --git a/ios/hybrids/VideoPlayer/HybridVideoPlayerFactory.swift b/ios/hybrids/VideoPlayer/HybridVideoPlayerFactory.swift new file mode 100644 index 00000000..9ab476dc --- /dev/null +++ b/ios/hybrids/VideoPlayer/HybridVideoPlayerFactory.swift @@ -0,0 +1,15 @@ +// +// HybridVideoPlayerFactory.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 09/10/2024. +// + +import Foundation +import NitroModules + +class HybridVideoPlayerFactory: HybridVideoPlayerFactorySpec { + func createPlayer(source: HybridVideoPlayerSourceSpec) throws -> HybridVideoPlayerSpec { + return try HybridVideoPlayer(source: source) + } +} diff --git a/ios/hybrids/VideoPlayerEmitter/HybridVideoPlayerEventEmitter.swift b/ios/hybrids/VideoPlayerEmitter/HybridVideoPlayerEventEmitter.swift new file mode 100644 index 00000000..0415985a --- /dev/null +++ b/ios/hybrids/VideoPlayerEmitter/HybridVideoPlayerEventEmitter.swift @@ -0,0 +1,203 @@ +// +// HybridVideoPlayerEventEmitter.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 02/05/2025. +// + +import Foundation +import NitroModules + +struct ListenerPair { + let id: UUID + let eventName: String + let callback: Any +} + +class HybridVideoPlayerEventEmitter: HybridVideoPlayerEventEmitterSpec { + var listeners: [ListenerPair] = [] + + // MARK: - Private helpers + + private func addListener(eventName: String, listener: T) -> ListenerSubscription { + let id = UUID() + listeners.append(ListenerPair(id: id, eventName: eventName, callback: listener)) + return ListenerSubscription(remove: { [weak self] in + self?.listeners.removeAll { $0.id == id } + }) + } + + private func emitEvent(eventName: String, invoke: (T) throws -> Void) { + for pair in listeners where pair.eventName == eventName { + if let callback = pair.callback as? T { + do { + try invoke(callback) + } catch { + print("[ReactNativeVideo] Error calling \(eventName) listener: \(error)") + } + } else { + print("[ReactNativeVideo] Invalid callback type for \(eventName)") + } + } + } + + // MARK: - Listener registration methods + + func addOnAudioBecomingNoisyListener(listener: @escaping () -> Void) throws -> ListenerSubscription { + addListener(eventName: "onAudioBecomingNoisy", listener: listener) + } + + func addOnAudioFocusChangeListener(listener: @escaping (Bool) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onAudioFocusChange", listener: listener) + } + + func addOnBandwidthUpdateListener(listener: @escaping (BandwidthData) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onBandwidthUpdate", listener: listener) + } + + func addOnBufferListener(listener: @escaping (Bool) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onBuffer", listener: listener) + } + + func addOnControlsVisibleChangeListener(listener: @escaping (Bool) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onControlsVisibleChange", listener: listener) + } + + func addOnEndListener(listener: @escaping () -> Void) throws -> ListenerSubscription { + addListener(eventName: "onEnd", listener: listener) + } + + func addOnExternalPlaybackChangeListener(listener: @escaping (Bool) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onExternalPlaybackChange", listener: listener) + } + + func addOnLoadListener(listener: @escaping (onLoadData) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onLoad", listener: listener) + } + + func addOnLoadStartListener(listener: @escaping (onLoadStartData) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onLoadStart", listener: listener) + } + + func addOnPlaybackStateChangeListener(listener: @escaping (onPlaybackStateChangeData) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onPlaybackStateChange", listener: listener) + } + + func addOnPlaybackRateChangeListener(listener: @escaping (Double) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onPlaybackRateChange", listener: listener) + } + + func addOnProgressListener(listener: @escaping (onProgressData) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onProgress", listener: listener) + } + + func addOnReadyToDisplayListener(listener: @escaping () -> Void) throws -> ListenerSubscription { + addListener(eventName: "onReadyToDisplay", listener: listener) + } + + func addOnSeekListener(listener: @escaping (Double) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onSeek", listener: listener) + } + + func addOnStatusChangeListener(listener: @escaping (VideoPlayerStatus) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onStatusChange", listener: listener) + } + + func addOnTimedMetadataListener(listener: @escaping (TimedMetadata) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onTimedMetadata", listener: listener) + } + + func addOnTextTrackDataChangedListener(listener: @escaping ([String]) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onTextTrackDataChanged", listener: listener) + } + + func addOnTrackChangeListener(listener: @escaping (Variant_NullType_TextTrack?) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onTrackChange", listener: listener) + } + + func addOnVolumeChangeListener(listener: @escaping (onVolumeChangeData) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onVolumeChange", listener: listener) + } + + func clearAllListeners() throws { + listeners.removeAll() + } + + // MARK: - Event emission methods + + func onAudioBecomingNoisy() { + emitEvent(eventName: "onAudioBecomingNoisy") { (callback: () throws -> Void) in try callback() } + } + + func onAudioFocusChange(_ hasFocus: Bool) { + emitEvent(eventName: "onAudioFocusChange") { (callback: (Bool) throws -> Void) in try callback(hasFocus) } + } + + func onBandwidthUpdate(_ data: BandwidthData) { + emitEvent(eventName: "onBandwidthUpdate") { (callback: (BandwidthData) throws -> Void) in try callback(data) } + } + + func onBuffer(_ isBuffering: Bool) { + emitEvent(eventName: "onBuffer") { (callback: (Bool) throws -> Void) in try callback(isBuffering) } + } + + func onControlsVisibleChange(_ isVisible: Bool) { + emitEvent(eventName: "onControlsVisibleChange") { (callback: (Bool) throws -> Void) in try callback(isVisible) } + } + + func onEnd() { + emitEvent(eventName: "onEnd") { (callback: () throws -> Void) in try callback() } + } + + func onExternalPlaybackChange(_ isExternalPlaybackActive: Bool) { + emitEvent(eventName: "onExternalPlaybackChange") { (callback: (Bool) throws -> Void) in try callback(isExternalPlaybackActive) } + } + + func onLoad(_ data: onLoadData) { + emitEvent(eventName: "onLoad") { (callback: (onLoadData) throws -> Void) in try callback(data) } + } + + func onLoadStart(_ data: onLoadStartData) { + emitEvent(eventName: "onLoadStart") { (callback: (onLoadStartData) throws -> Void) in try callback(data) } + } + + func onPlaybackStateChange(_ data: onPlaybackStateChangeData) { + emitEvent(eventName: "onPlaybackStateChange") { (callback: (onPlaybackStateChangeData) throws -> Void) in try callback(data) } + } + + func onPlaybackRateChange(_ rate: Double) { + emitEvent(eventName: "onPlaybackRateChange") { (callback: (Double) throws -> Void) in try callback(rate) } + } + + func onProgress(_ data: onProgressData) { + emitEvent(eventName: "onProgress") { (callback: (onProgressData) throws -> Void) in try callback(data) } + } + + func onReadyToDisplay() { + emitEvent(eventName: "onReadyToDisplay") { (callback: () throws -> Void) in try callback() } + } + + func onSeek(_ position: Double) { + emitEvent(eventName: "onSeek") { (callback: (Double) throws -> Void) in try callback(position) } + } + + func onStatusChange(_ status: VideoPlayerStatus) { + emitEvent(eventName: "onStatusChange") { (callback: (VideoPlayerStatus) throws -> Void) in try callback(status) } + } + + func onTimedMetadata(_ metadata: TimedMetadata) { + emitEvent(eventName: "onTimedMetadata") { (callback: (TimedMetadata) throws -> Void) in try callback(metadata) } + } + + func onTextTrackDataChanged(_ tracks: [String]) { + emitEvent(eventName: "onTextTrackDataChanged") { (callback: ([String]) throws -> Void) in try callback(tracks) } + } + + func onTrackChange(_ track: Variant_NullType_TextTrack?) { + emitEvent(eventName: "onTrackChange") { (callback: (Variant_NullType_TextTrack?) throws -> Void) in try callback(track) } + } + + func onVolumeChange(_ data: onVolumeChangeData) { + emitEvent(eventName: "onVolumeChange") { (callback: (onVolumeChangeData) throws -> Void) in try callback(data) } + } +} diff --git a/ios/hybrids/VideoPlayerSource/HybridVideoPlayerSource.swift b/ios/hybrids/VideoPlayerSource/HybridVideoPlayerSource.swift new file mode 100644 index 00000000..07a75c54 --- /dev/null +++ b/ios/hybrids/VideoPlayerSource/HybridVideoPlayerSource.swift @@ -0,0 +1,166 @@ +// +// HybridVideoPlayerSource.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 23/09/2024. +// + +import AVFoundation +import Foundation +import NitroModules + +class HybridVideoPlayerSource: HybridVideoPlayerSourceSpec, NativeVideoPlayerSourceSpec { + var asset: AVURLAsset? + var uri: String + var config: NativeVideoConfig + + var drmManager: DRMManagerSpec? + + let url: URL + private let sourceLoader = SourceLoader() + + init(config: NativeVideoConfig) throws { + self.uri = config.uri + self.config = config + + guard let url = URL(string: uri) else { + throw SourceError.invalidUri(uri: uri).error() + } + + self.url = url + + super.init() + + if config.drm != nil { + // Try to get the DRM manager + // If no DRM manager is found, it will throw an error + _ = try PluginsRegistry.shared.getDrmManager(source: self) + } + } + + deinit { + releaseAsset() + } + + func getAssetInformationAsync() -> Promise { + let promise = Promise() + + Task.detached(priority: .utility) { [weak self] in + guard let self else { + promise.reject( + withError: LibraryError.deallocated(objectName: "HybridVideoPlayerSource").error()) + return + } + + do { + let videoInformation = try await self.sourceLoader.load(priority: .utility) { + if self.url.isFileURL { + try VideoFileHelper.validateReadPermission(for: self.url) + } + + try await self.initializeAsset() + + guard let asset = self.asset else { + throw PlayerError.assetNotInitialized.error() + } + + return try await asset.getAssetInformation() + } + + promise.resolve(withResult: videoInformation) + } catch { + if error is CancellationError { + promise.reject(withError: SourceError.cancelled.error()) + } else { + promise.reject(withError: error) + } + } + } + + return promise + } + + func initializeAsset() async throws { + guard asset == nil else { + return + } + + if let headers = config.headers { + let options = [ + "AVURLAssetHTTPHeaderFieldsKey": headers + ] + asset = AVURLAsset(url: url, options: options) + } else { + asset = AVURLAsset(url: url) + } + + guard let asset else { + throw SourceError.failedToInitializeAsset.error() + } + + do { + if let drmParams = config.drm { + drmManager = try PluginsRegistry.shared.getDrmManager(source: self) + + guard let drmManager else { + throw LibraryError.DRMPluginNotFound.error() + } + + do { + try drmManager.createContentKeyRequest(for: asset, drmParams: drmParams) + } catch { + print("[ReactNativeVideo] Failed to create content key request for DRM: \(drmParams)") + } + } + + // Code browned from expo-video https://github.com/expo/expo/blob/ea17c9b1ce5111e1454b089ba381f3feb93f33cc/packages/expo-video/ios/VideoPlayerItem.swift#L40C30-L40C73 + // If we don't load those properties, they will be loaded on main thread causing lags + _ = try? await asset.load(.duration, .preferredTransform, .isPlayable) as Any + + try Task.checkCancellation() + } catch { + self.asset = nil + if error is CancellationError { + throw SourceError.cancelled.error() + } + throw error + } + } + + func getAsset() async throws -> AVURLAsset { + if let asset { + return asset + } + + do { + try await sourceLoader.load { + try await self.initializeAsset() + } + + guard let asset else { + throw SourceError.failedToInitializeAsset.error() + } + + return asset + } catch { + if error is CancellationError { + self.asset = nil + throw SourceError.cancelled.error() + } + throw error + } + } + + func releaseAsset() { + sourceLoader.cancelSync() + asset = nil + } + + var memorySize: Int { + var size = 0 + + size += asset?.estimatedMemoryUsage ?? 0 + + return size + } +} diff --git a/ios/hybrids/VideoPlayerSource/HybridVideoPlayerSourceFactory.swift b/ios/hybrids/VideoPlayerSource/HybridVideoPlayerSourceFactory.swift new file mode 100644 index 00000000..8b6caa2a --- /dev/null +++ b/ios/hybrids/VideoPlayerSource/HybridVideoPlayerSourceFactory.swift @@ -0,0 +1,29 @@ +// +// HybridVideoPlayerSourceFactory.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 23/09/2024. +// + +import Foundation + +class HybridVideoPlayerSourceFactory: HybridVideoPlayerSourceFactorySpec { + func fromVideoConfig(config: NativeVideoConfig) throws + -> any HybridVideoPlayerSourceSpec + { + return try HybridVideoPlayerSource(config: config) + } + + func fromUri(uri: String) throws -> HybridVideoPlayerSourceSpec { + let config = NativeVideoConfig( + uri: uri, + externalSubtitles: nil, + drm: nil, + headers: nil, + bufferConfig: nil, + metadata: nil, + initializeOnCreation: true + ) + return try HybridVideoPlayerSource(config: config) + } +} diff --git a/ios/hybrids/VideoPlayerSource/SourceLoader.swift b/ios/hybrids/VideoPlayerSource/SourceLoader.swift new file mode 100644 index 00000000..56257ad7 --- /dev/null +++ b/ios/hybrids/VideoPlayerSource/SourceLoader.swift @@ -0,0 +1,83 @@ +// +// SourceLoader.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 27/01/2025. +// + +import Foundation + +private class AnyCancellable { + private let _cancel: () -> Void + + init(_ cancel: @escaping () -> Void) { + self._cancel = cancel + } + + func cancel() { + _cancel() + } +} + +actor SourceLoaderActor { + private var currentCancellable: AnyCancellable? + + func load(priority: TaskPriority, operation: @escaping () async throws -> T) async throws -> T + { + await cancelCurrentTask() + + let cancellableTask = Task(priority: priority) { + try await operation() + } + + let cancellable = AnyCancellable { + cancellableTask.cancel() + } + currentCancellable = cancellable + + do { + let result = try await cancellableTask.value + if currentCancellable === cancellable { + currentCancellable = nil + } + return result + } catch { + if currentCancellable === cancellable { + currentCancellable = nil + } + if error is CancellationError { + throw error + } + throw error + } + } + + func cancel() async { + await cancelCurrentTask() + } + + private func cancelCurrentTask() async { + currentCancellable?.cancel() + currentCancellable = nil + } +} + +class SourceLoader { + private let actor = SourceLoaderActor() + + func load(priority: TaskPriority = .userInitiated, operation: @escaping () async throws -> T) + async throws -> T + { + return try await actor.load(priority: priority, operation: operation) + } + + func cancel() async { + await actor.cancel() + } + + func cancelSync() { + Task { + await actor.cancel() + } + } +} diff --git a/ios/hybrids/VideoViewViewManager/HybridVideoViewViewManager.swift b/ios/hybrids/VideoViewViewManager/HybridVideoViewViewManager.swift new file mode 100644 index 00000000..d63643eb --- /dev/null +++ b/ios/hybrids/VideoViewViewManager/HybridVideoViewViewManager.swift @@ -0,0 +1,266 @@ +// +// HybridVideoViewViewManager.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 23/09/2024. +// + +import Foundation +import AVKit +import NitroModules + +struct ViewListenerPair { + let id: UUID + let eventName: String + let callback: Any +} + +class HybridVideoViewViewManager: HybridVideoViewViewManagerSpec { + weak var view: VideoComponentView? + var listeners: [ViewListenerPair] = [] + + let DEALOCATED_WARNING = "ReactNativeVideo: VideoComponentView is no longer available. It is likely that the view was deallocated." + + init(nitroId: Double) throws { + guard let view = VideoComponentView.globalViewsMap.object(forKey: NSNumber(value: nitroId)) else { + throw VideoViewError.viewNotFound(nitroId: nitroId).error() + } + + self.view = view + super.init() + view.delegate = VideoViewDelegate(viewManager: self) + } + + // MARK: - Private helpers + + private func addListener(eventName: String, listener: T) -> ListenerSubscription { + let id = UUID() + listeners.append(ViewListenerPair(id: id, eventName: eventName, callback: listener)) + return ListenerSubscription(remove: { [weak self] in + self?.listeners.removeAll { $0.id == id } + }) + } + + private func emitEvent(eventName: String, invoke: (T) throws -> Void) { + for pair in listeners where pair.eventName == eventName { + if let callback = pair.callback as? T { + do { + try invoke(callback) + } catch { + print("[ReactNativeVideo] Error calling \(eventName) listener: \(error)") + } + } else { + print("[ReactNativeVideo] Invalid callback type for \(eventName)") + } + } + } + + // MARK: - Properties + + weak var player: (any HybridVideoPlayerSpec)? { + get { + guard let view = view else { + print(DEALOCATED_WARNING) + return nil + } + return view.player + } + set { + guard let view = view else { + print(DEALOCATED_WARNING) + return + } + view.player = newValue + } + } + + var controls: Bool { + get { + guard let view else { + print(DEALOCATED_WARNING) + return false + } + + return view.controls + } + set { + guard let view else { + print(DEALOCATED_WARNING) + return + } + + view.controls = newValue + } + } + + var pictureInPicture: Bool { + get { + guard let view else { + print(DEALOCATED_WARNING) + return false + } + + return view.allowsPictureInPicturePlayback + } + set { + guard let view else { + print(DEALOCATED_WARNING) + return + } + + view.allowsPictureInPicturePlayback = newValue + } + } + + var autoEnterPictureInPicture: Bool { + get { + guard let view else { + print(DEALOCATED_WARNING) + return false + } + + return view.autoEnterPictureInPicture + } + set { + guard let view else { + print(DEALOCATED_WARNING) + return + } + + view.autoEnterPictureInPicture = newValue + } + } + + var resizeMode: ResizeMode { + get { + guard let view else { + print(DEALOCATED_WARNING) + return .none + } + + return view.resizeMode + } + set { + guard let view else { + print(DEALOCATED_WARNING) + return + } + + view.resizeMode = newValue + } + } + + var keepScreenAwake: Bool { + get { + guard let view else { + print(DEALOCATED_WARNING) + return false + } + + return view.keepScreenAwake + } + set { + guard let view else { + print(DEALOCATED_WARNING) + return + } + + view.keepScreenAwake = newValue + } + } + + // Android only - no-op on iOS + var surfaceType: SurfaceType = .surface + + func enterFullscreen() throws { + guard let view else { + throw VideoViewError.viewIsDeallocated.error() + } + + try view.enterFullscreen() + } + + func exitFullscreen() throws { + guard let view else { + throw VideoViewError.viewIsDeallocated.error() + } + + try view.exitFullscreen() + } + + func enterPictureInPicture() throws { + guard let view else { + throw VideoViewError.viewIsDeallocated.error() + } + + try view.startPictureInPicture() + } + + func exitPictureInPicture() throws { + guard let view else { + throw VideoViewError.viewIsDeallocated.error() + } + + try view.stopPictureInPicture() + } + + func canEnterPictureInPicture() -> Bool { + return AVPictureInPictureController.isPictureInPictureSupported() + } + + // MARK: - Listener registration methods + + func addOnPictureInPictureChangeListener(listener: @escaping (Bool) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onPictureInPictureChange", listener: listener) + } + + func addOnFullscreenChangeListener(listener: @escaping (Bool) -> Void) throws -> ListenerSubscription { + addListener(eventName: "onFullscreenChange", listener: listener) + } + + func addWillEnterFullscreenListener(listener: @escaping () -> Void) throws -> ListenerSubscription { + addListener(eventName: "willEnterFullscreen", listener: listener) + } + + func addWillExitFullscreenListener(listener: @escaping () -> Void) throws -> ListenerSubscription { + addListener(eventName: "willExitFullscreen", listener: listener) + } + + func addWillEnterPictureInPictureListener(listener: @escaping () -> Void) throws -> ListenerSubscription { + addListener(eventName: "willEnterPictureInPicture", listener: listener) + } + + func addWillExitPictureInPictureListener(listener: @escaping () -> Void) throws -> ListenerSubscription { + addListener(eventName: "willExitPictureInPicture", listener: listener) + } + + func clearAllListeners() throws { + listeners.removeAll() + } + + // MARK: - Event emission methods + + func onPictureInPictureChange(_ isActive: Bool) { + emitEvent(eventName: "onPictureInPictureChange") { (callback: (Bool) throws -> Void) in try callback(isActive) } + } + + func onFullscreenChange(_ isActive: Bool) { + emitEvent(eventName: "onFullscreenChange") { (callback: (Bool) throws -> Void) in try callback(isActive) } + } + + func willEnterFullscreen() { + emitEvent(eventName: "willEnterFullscreen") { (callback: () throws -> Void) in try callback() } + } + + func willExitFullscreen() { + emitEvent(eventName: "willExitFullscreen") { (callback: () throws -> Void) in try callback() } + } + + func willEnterPictureInPicture() { + emitEvent(eventName: "willEnterPictureInPicture") { (callback: () throws -> Void) in try callback() } + } + + func willExitPictureInPicture() { + emitEvent(eventName: "willExitPictureInPicture") { (callback: () throws -> Void) in try callback() } + } +} diff --git a/ios/hybrids/VideoViewViewManager/HybridVideoViewViewManagerFactory.swift b/ios/hybrids/VideoViewViewManager/HybridVideoViewViewManagerFactory.swift new file mode 100644 index 00000000..4c26a092 --- /dev/null +++ b/ios/hybrids/VideoViewViewManager/HybridVideoViewViewManagerFactory.swift @@ -0,0 +1,14 @@ +// +// HybridVideoViewViewManagerFactory.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 23/09/2024. +// + +import Foundation + +class HybridVideoViewViewManagerFactory: HybridVideoViewViewManagerFactorySpec { + func createViewManager(nitroId: Double) throws -> any HybridVideoViewViewManagerSpec { + return try HybridVideoViewViewManager(nitroId: nitroId) + } +} diff --git a/ios/view/VideoComponentView.swift b/ios/view/VideoComponentView.swift new file mode 100644 index 00000000..40d28c20 --- /dev/null +++ b/ios/view/VideoComponentView.swift @@ -0,0 +1,274 @@ +// +// VideoComponent.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 30/09/2024. +// + +import AVFoundation +import AVKit +import Foundation +import UIKit + +@objc public class VideoComponentView: UIView { + public weak var player: HybridVideoPlayerSpec? = nil { + didSet { + guard let player = player as? HybridVideoPlayer else { return } + configureAVPlayerViewController(with: player.player) + } + } + + var delegate: VideoViewDelegate? + private var playerView: UIView? = nil + + private var observer: VideoComponentViewObserver? { + didSet { + playerViewController?.delegate = observer + observer?.updatePlayerViewControllerObservers() + } + } + + private var _keepScreenAwake: Bool = false + var keepScreenAwake: Bool { + get { + guard let player = player as? HybridVideoPlayer else { return false } + return player.player.preventsDisplaySleepDuringVideoPlayback + } + set { + guard let player = player as? HybridVideoPlayer else { return } + player.player.preventsDisplaySleepDuringVideoPlayback = newValue + _keepScreenAwake = newValue + } + } + + var playerViewController: AVPlayerViewController? { + didSet { + guard let observer, let playerViewController else { return } + playerViewController.delegate = observer + observer.updatePlayerViewControllerObservers() + } + } + + public var controls: Bool = false { + didSet { + DispatchQueue.main.async { [weak self] in + guard let self = self, let playerViewController = self.playerViewController else { return } + playerViewController.showsPlaybackControls = self.controls + } + } + } + + public var allowsPictureInPicturePlayback: Bool = false { + didSet { + DispatchQueue.main.async { [weak self] in + guard let self = self, let playerViewController = self.playerViewController else { return } + + VideoManager.shared.requestAudioSessionUpdate() + playerViewController.allowsPictureInPicturePlayback = self.allowsPictureInPicturePlayback + } + } + } + + public var autoEnterPictureInPicture: Bool = false { + didSet { + DispatchQueue.main.async { [weak self] in + guard let self = self, let playerViewController = self.playerViewController else { return } + + VideoManager.shared.requestAudioSessionUpdate() + playerViewController.canStartPictureInPictureAutomaticallyFromInline = + self.autoEnterPictureInPicture + } + } + } + + public var resizeMode: ResizeMode = .none { + didSet { + DispatchQueue.main.async { [weak self] in + guard let self = self, let playerViewController = self.playerViewController else { return } + playerViewController.videoGravity = resizeMode.toVideoGravity() + } + } + } + + @objc public var nitroId: NSNumber = -1 { + didSet { + VideoComponentView.globalViewsMap.setObject(self, forKey: nitroId) + } + } + + @objc public static var globalViewsMap: NSMapTable = + .strongToWeakObjects() + + @objc public override init(frame: CGRect) { + super.init(frame: frame) + VideoManager.shared.register(view: self) + setupPlayerView() + observer = VideoComponentViewObserver(view: self) + } + + deinit { + VideoManager.shared.unregister(view: self) + } + + @objc public required init?(coder: NSCoder) { + super.init(coder: coder) + setupPlayerView() + } + + func setNitroId(nitroId: NSNumber) { + self.nitroId = nitroId + } + + private func setupPlayerView() { + // Create a UIView to hold the video player layer + playerView = UIView(frame: self.bounds) + playerView?.translatesAutoresizingMaskIntoConstraints = false + if let playerView = playerView { + addSubview(playerView) + NSLayoutConstraint.activate([ + playerView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + playerView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + playerView.topAnchor.constraint(equalTo: self.topAnchor), + playerView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + ]) + } + } + + public func configureAVPlayerViewController(with player: AVPlayer) { + DispatchQueue.main.async { [weak self] in + guard let self = self, let playerView = self.playerView else { return } + + // Skip reconfiguration if player hasn't changed and controller already exists + if let existingController = self.playerViewController, + existingController.player === player + { + return + } + + // Remove previous controller if any + self.playerViewController?.willMove(toParent: nil) + self.playerViewController?.view.removeFromSuperview() + self.playerViewController?.removeFromParent() + + let controller = AVPlayerViewController() + controller.player = player + controller.showsPlaybackControls = controls + controller.videoGravity = self.resizeMode.toVideoGravity() + controller.view.frame = playerView.bounds + controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + controller.view.backgroundColor = .clear + + // We manage this manually in NowPlayingInfoCenterManager + controller.updatesNowPlayingInfoCenter = false + + if #available(iOS 16.0, *) { + if let initialSpeed = controller.speeds.first(where: { $0.rate == player.rate }) { + controller.selectSpeed(initialSpeed) + } + } + // Disable video frame analysis to prevent visual lookup + if #available(iOS 16.0, iPadOS 16.0, macCatalyst 18.0, *) { + controller.allowsVideoFrameAnalysis = false + } + + // Find nearest UIViewController + if let parentVC = self.findViewController() { + parentVC.addChild(controller) + playerView.addSubview(controller.view) + controller.didMove(toParent: parentVC) + self.playerViewController = controller + } + } + } + + // Helper to find nearest UIViewController + private func findViewController() -> UIViewController? { + var responder: UIResponder? = self + while let r = responder { + if let vc = r as? UIViewController { + return vc + } + responder = r.next + } + return nil + } + + public override func willMove(toSuperview newSuperview: UIView?) { + super.willMove(toSuperview: newSuperview) + + if newSuperview == nil { + PluginsRegistry.shared.notifyVideoViewDestroyed(view: self) + + // We want to disable this when view is about to unmount + if keepScreenAwake { + keepScreenAwake = false + } + } else { + PluginsRegistry.shared.notifyVideoViewCreated(view: self) + + // We want to restore keepScreenAwake after component remount + if _keepScreenAwake { + keepScreenAwake = true + } + } + } + + public override func layoutSubviews() { + super.layoutSubviews() + + // Update the frame of the playerViewController's view when the view's layout changes + playerViewController?.view.frame = playerView?.bounds ?? .zero + playerViewController?.contentOverlayView?.frame = playerView?.bounds ?? .zero + for subview in playerViewController?.contentOverlayView?.subviews ?? [] { + subview.frame = playerView?.bounds ?? .zero + } + } + + public func enterFullscreen() throws { + guard let playerViewController else { + throw VideoViewError.viewIsDeallocated.error() + } + + DispatchQueue.main.async { + playerViewController.enterFullscreen(animated: true) + } + } + + public func exitFullscreen() throws { + guard let playerViewController else { + throw VideoViewError.viewIsDeallocated.error() + } + + DispatchQueue.main.async { + playerViewController.exitFullscreen(animated: true) + } + } + + public func startPictureInPicture() throws { + guard let playerViewController else { + throw VideoViewError.viewIsDeallocated.error() + } + + guard AVPictureInPictureController.isPictureInPictureSupported() else { + throw VideoViewError.pictureInPictureNotSupported.error() + } + + DispatchQueue.main.async { + // Here we skip error handling for simplicity + // We do check for PiP support earlier in the code + try? playerViewController.startPictureInPicture() + } + } + + public func stopPictureInPicture() throws { + guard let playerViewController else { + throw VideoViewError.viewIsDeallocated.error() + } + + DispatchQueue.main.async { + // Here we skip error handling for simplicity + // We do check for PiP support earlier in the code + playerViewController.stopPictureInPicture() + } + } +} diff --git a/ios/view/VideoComponentViewObserver.swift b/ios/view/VideoComponentViewObserver.swift new file mode 100644 index 00000000..b6d4f936 --- /dev/null +++ b/ios/view/VideoComponentViewObserver.swift @@ -0,0 +1,176 @@ +// +// VideoComponentViewObserver.swift +// ReactNativeVideo +// +// Created by Krzysztof Moch on 06/05/2025. +// + +import Foundation +import AVKit +import AVFoundation + +protocol VideoComponentViewDelegate: AnyObject { + func onPictureInPictureChange(_ isActive: Bool) + func onFullscreenChange(_ isActive: Bool) + func willEnterFullscreen() + func willExitFullscreen() + func willEnterPictureInPicture() + func willExitPictureInPicture() + func onReadyToDisplay() +} + +// Map delegate methods to view manager methods +final class VideoViewDelegate: NSObject, VideoComponentViewDelegate { + weak var viewManager: HybridVideoViewViewManager? + + init(viewManager: HybridVideoViewViewManager) { + self.viewManager = viewManager + } + + func onPictureInPictureChange(_ isActive: Bool) { + viewManager?.onPictureInPictureChange(isActive) + } + + func onFullscreenChange(_ isActive: Bool) { + viewManager?.onFullscreenChange(isActive) + } + + func willEnterFullscreen() { + viewManager?.willEnterFullscreen() + } + + func willExitFullscreen() { + viewManager?.willExitFullscreen() + } + + func willEnterPictureInPicture() { + viewManager?.willEnterPictureInPicture() + } + + func willExitPictureInPicture() { + viewManager?.willExitPictureInPicture() + } + + func onReadyToDisplay() { + if let player = viewManager?.player as? HybridVideoPlayer { + player._eventEmitter?.onReadyToDisplay() + } + } +} + +class VideoComponentViewObserver: NSObject, AVPlayerViewControllerDelegate { + private weak var view: VideoComponentView? + + var delegate: VideoViewDelegate? { + get { + return view?.delegate + } + } + + var playerViewController: AVPlayerViewController? { + return view?.playerViewController + } + + // playerViewController observers + var onReadyToDisplayObserver: NSKeyValueObservation? + + init(view: VideoComponentView) { + self.view = view + super.init() + } + + func initializePlayerViewContorollerObservers() { + guard let playerViewController = playerViewController else { + return + } + + onReadyToDisplayObserver = playerViewController.observe(\.isReadyForDisplay, options: [.new]) { [weak self] _, change in + guard let self = self else { return } + if change.newValue == true { + self.delegate?.onReadyToDisplay() + } + } + } + + func removePlayerViewControllerObservers() { + onReadyToDisplayObserver?.invalidate() + onReadyToDisplayObserver = nil + } + + func updatePlayerViewControllerObservers() { + removePlayerViewControllerObservers() + initializePlayerViewContorollerObservers() + } + + func playerViewControllerDidStartPictureInPicture(_: AVPlayerViewController) { + delegate?.onPictureInPictureChange(true) + } + + func playerViewControllerDidStopPictureInPicture(_: AVPlayerViewController) { + delegate?.onPictureInPictureChange(false) + } + + func playerViewControllerWillStartPictureInPicture(_: AVPlayerViewController) { + delegate?.willEnterPictureInPicture() + } + + func playerViewControllerWillStopPictureInPicture(_: AVPlayerViewController) { + delegate?.willExitPictureInPicture() + } + + func playerViewControllerRestoreUserInterfaceForPictureInPictureStop( + _ playerViewController: AVPlayerViewController, + completionHandler: @escaping (Bool) -> Void + ) { + let isViewAttached = view?.window != nil + completionHandler(isViewAttached) + } + + func playerViewController( + _: AVPlayerViewController, + willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator + ) { + delegate?.willExitFullscreen() + + coordinator.animate(alongsideTransition: nil) { [weak self] context in + guard let self = self else { return } + + if context.isCancelled { + // iOS bug: window.isUserInteractionEnabled is left as false after cancelled fullscreen dismiss + if let window = self.playerViewController?.view.window, !window.isUserInteractionEnabled { + window.isUserInteractionEnabled = true + } + + self.delegate?.willEnterFullscreen() + + return + } + + self.delegate?.onFullscreenChange(false) + } + } + + func playerViewController( + _: AVPlayerViewController, + willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator + ) { + delegate?.willEnterFullscreen() + + coordinator.animate(alongsideTransition: nil) { [weak self] context in + guard let self = self else { return } + + if context.isCancelled { + // iOS bug: window.isUserInteractionEnabled is left as false after cancelled fullscreen transition + if let window = self.playerViewController?.view.window, !window.isUserInteractionEnabled { + window.isUserInteractionEnabled = true + } + + self.delegate?.willExitFullscreen() + + return + } + + self.delegate?.onFullscreenChange(true) + } + } +} diff --git a/ios/view/fabric/RCTVideoViewComponentView.h b/ios/view/fabric/RCTVideoViewComponentView.h new file mode 100644 index 00000000..a2e8c59d --- /dev/null +++ b/ios/view/fabric/RCTVideoViewComponentView.h @@ -0,0 +1,10 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface RCTVideoViewComponentView : RCTViewComponentView + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/view/fabric/RCTVideoViewComponentView.mm b/ios/view/fabric/RCTVideoViewComponentView.mm new file mode 100644 index 00000000..3f6d5a42 --- /dev/null +++ b/ios/view/fabric/RCTVideoViewComponentView.mm @@ -0,0 +1,94 @@ +#import "RCTVideoViewComponentView.h" + +#import +#import +#import +#import + +#import "RCTFabricComponentsPlugins.h" + +#import "ReactNativeVideo-Swift-Cxx-Umbrella.hpp" + +#if __has_include("ReactNativeVideo/ReactNativeVideo-Swift.h") +#import "ReactNativeVideo/ReactNativeVideo-Swift.h" +#else +#import "ReactNativeVideo-Swift.h" +#endif + +using namespace facebook::react; + +@interface RCTVideoViewComponentView () +@end + +@implementation RCTVideoViewComponentView { + VideoComponentView *_view; + int _nitroId; +} + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = + std::make_shared(); + _props = defaultProps; + + _view = [[VideoComponentView alloc] initWithFrame:frame]; + + self.contentView = _view; + } + + // -1 means that nitroId wasn't set yet + _nitroId = -1; + + return self; +} + +- (void)updateProps:(Props::Shared const &)props + oldProps:(Props::Shared const &)oldProps { + const auto &oldViewProps = + *std::static_pointer_cast(_props); + const auto &newViewProps = + *std::static_pointer_cast(props); + + if (oldViewProps.nitroId != newViewProps.nitroId) { + [self setNitroId:newViewProps.nitroId]; + } + + [super updateProps:props oldProps:oldProps]; +} + +- (void)setNitroId:(int)nitroId { + _nitroId = nitroId; + [_view setNitroId:[NSNumber numberWithInt:nitroId]]; + [self onNitroIdChange:nitroId]; +} + ++ (BOOL)shouldBeRecycled +{ + return NO; +} + +// Event emitter convenience method +- (void)onNitroIdChange:(int)nitroId { + auto eventEmitter = + std::dynamic_pointer_cast(_eventEmitter); + if (!eventEmitter || nitroId == -1) { + return; + } + + eventEmitter->onNitroIdChange({.nitroId = nitroId}); +} + +- (void)updateEventEmitter:(EventEmitter::Shared const &)eventEmitter { + [super updateEventEmitter:eventEmitter]; + [self onNitroIdChange:_nitroId]; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider { + return concreteComponentDescriptorProvider(); +} + +Class RNCVideoViewCls(void) { + return RCTVideoViewComponentView.class; +} + +@end diff --git a/ios/view/fabric/RCTVideoViewViewManager.mm b/ios/view/fabric/RCTVideoViewViewManager.mm new file mode 100644 index 00000000..5f638ff3 --- /dev/null +++ b/ios/view/fabric/RCTVideoViewViewManager.mm @@ -0,0 +1,14 @@ +#import +#import +#import "RCTBridge.h" + +@interface RCTVideoViewViewManager : RCTViewManager +@end + +@implementation RCTVideoViewViewManager + +RCT_EXPORT_MODULE(RNCVideoView) + +RCT_EXPORT_VIEW_PROPERTY(nitroId, NSNumber) + +@end diff --git a/ios/view/paper/RCTVideoViewComponentView.h b/ios/view/paper/RCTVideoViewComponentView.h new file mode 100644 index 00000000..9050b578 --- /dev/null +++ b/ios/view/paper/RCTVideoViewComponentView.h @@ -0,0 +1,9 @@ +#import + +@interface RCTVideoViewComponentView : RCTView + +@property (nonatomic, copy) NSNumber *nitroId; +@property (nonatomic, copy) RCTDirectEventBlock onNitroIdChange; + +@end + diff --git a/ios/view/paper/RCTVideoViewComponentView.mm b/ios/view/paper/RCTVideoViewComponentView.mm new file mode 100644 index 00000000..bb7fa43b --- /dev/null +++ b/ios/view/paper/RCTVideoViewComponentView.mm @@ -0,0 +1,45 @@ +#import "RCTVideoViewComponentView.h" + +#import "ReactNativeVideo-Swift-Cxx-Umbrella.hpp" + +#if __has_include("ReactNativeVideo/ReactNativeVideo-Swift.h") +#import "ReactNativeVideo/ReactNativeVideo-Swift.h" +#else +#import "ReactNativeVideo-Swift.h" +#endif + +@implementation RCTVideoViewComponentView { + VideoComponentView *_view; +} + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self) { + // Initialize VideoComponentView with the given frame + _view = [[VideoComponentView alloc] initWithFrame:frame]; + _view.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:_view]; + + // Set up constraints to make VideoComponentView fill + // RCTVideoViewComponentView + [NSLayoutConstraint activateConstraints:@[ + [_view.leadingAnchor constraintEqualToAnchor:self.leadingAnchor], + [_view.trailingAnchor constraintEqualToAnchor:self.trailingAnchor], + [_view.topAnchor constraintEqualToAnchor:self.topAnchor], + [_view.bottomAnchor constraintEqualToAnchor:self.bottomAnchor] + ]]; + } + return self; +} + +- (void)setNitroId:(NSNumber *)nitroId { + _nitroId = nitroId; + [_view setNitroId:nitroId]; + + // Emit the onNitroIdChange event when nitroId is updated + if (self.onNitroIdChange) { + self.onNitroIdChange(@{@"nitroId" : nitroId}); + } +} + +@end diff --git a/ios/view/paper/RCTVideoViewViewManager.m b/ios/view/paper/RCTVideoViewViewManager.m new file mode 100644 index 00000000..685f4bf7 --- /dev/null +++ b/ios/view/paper/RCTVideoViewViewManager.m @@ -0,0 +1,18 @@ +#import +#import "RCTEventDispatcher.h" +#import "RCTVideoViewComponentView.h" + +@interface RCTVideoViewViewManager : RCTViewManager +@end + +@implementation RCTVideoViewViewManager + +RCT_EXPORT_MODULE(RNCVideoView) +RCT_EXPORT_VIEW_PROPERTY(nitroId, NSNumber) +RCT_EXPORT_VIEW_PROPERTY(onNitroIdChange, RCTDirectEventBlock) + +- (UIView *)view { + return [[RCTVideoViewComponentView alloc] init]; +} + +@end diff --git a/lib/commonjs/core/VideoPlayer.js b/lib/commonjs/core/VideoPlayer.js new file mode 100644 index 00000000..124e9fcc --- /dev/null +++ b/lib/commonjs/core/VideoPlayer.js @@ -0,0 +1,276 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.VideoPlayer = void 0; +var _reactNative = require("react-native"); +var _reactNativeNitroModules = require("react-native-nitro-modules"); +var _VideoError = require("./types/VideoError.js"); +var _playerFactory = require("./utils/playerFactory.js"); +var _sourceFactory = require("./utils/sourceFactory.js"); +var _VideoPlayerEvents = require("./events/VideoPlayerEvents"); +class VideoPlayer extends _VideoPlayerEvents.VideoPlayerEvents { + get player() { + if (this._player === undefined) { + throw new _VideoError.VideoRuntimeError('player/released', "You can't access player after it's released"); + } + return this._player; + } + constructor(source) { + const hybridSource = (0, _sourceFactory.createSource)(source); + const player = (0, _playerFactory.createPlayer)(hybridSource); + + // Initialize events + super(player.eventEmitter); + this._player = player; + } + + /** + * Releases the player's native resources and releases native state. + * @internal + */ + __destroy() { + if (this._player === undefined) return; + this.clearAllEvents(); + try { + this.player.release(); + } catch (error) { + // Best effort cleanup: teardown must never crash app unmount. + console.error('Failed to cleanup native player resources', error); + } + + // We leave hybrid object to be cleaned up by garbage collector + // So we update memory size to ensure that memory is released + // when needed + this.updateMemorySize(); + + // We wait for 5s to let late events that were triggered before release to be processed + if (this._releaseTimeout !== undefined) { + clearTimeout(this._releaseTimeout); + } + this._releaseTimeout = setTimeout(() => { + this._player = undefined; + this._releaseTimeout = undefined; + }, 5000); + } + + /** + * Returns the native (hybrid) player instance. + * Should not be used outside of the module. + * @internal + */ + __getNativePlayer() { + return this.player; + } + + /** + * Handles parsing native errors to VideoRuntimeError and calling onError if provided + * @internal + */ + throwError(error) { + const parsedError = (0, _VideoError.tryParseNativeVideoError)(error); + if (parsedError instanceof _VideoError.VideoRuntimeError && this.triggerJSEvent('onError', parsedError)) { + // We don't throw errors if onError is provided + return; + } + throw parsedError; + } + + /** + * Updates the memory size of the player and its source. Should be called after any operation that changes the memory size of the player or its source. + * @internal + */ + updateMemorySize() { + _reactNativeNitroModules.NitroModules.updateMemorySize(this.player); + _reactNativeNitroModules.NitroModules.updateMemorySize(this.player.source); + } + + /** + * Wraps a promise to try parsing native errors to VideoRuntimeError + * @internal + */ + wrapPromise(promise) { + return new Promise((resolve, reject) => { + promise.then(resolve).catch(error => { + reject(this.throwError(error)); + }); + }); + } + + // Source + get source() { + return this.player.source; + } + + // Status + get status() { + return this.player.status; + } + + // Duration + get duration() { + return this.player.duration; + } + + // Volume + get volume() { + return this.player.volume; + } + set volume(value) { + this.player.volume = value; + } + + // Current Time + get currentTime() { + return this.player.currentTime; + } + set currentTime(value) { + this.player.currentTime = value; + } + + // Muted + get muted() { + return this.player.muted; + } + set muted(value) { + this.player.muted = value; + } + + // Loop + get loop() { + return this.player.loop; + } + set loop(value) { + this.player.loop = value; + } + + // Rate + get rate() { + return this.player.rate; + } + set rate(value) { + this.player.rate = value; + } + + // Mix Audio Mode + get mixAudioMode() { + return this.player.mixAudioMode; + } + set mixAudioMode(value) { + this.player.mixAudioMode = value; + } + + // Ignore Silent Switch Mode + get ignoreSilentSwitchMode() { + return this.player.ignoreSilentSwitchMode; + } + set ignoreSilentSwitchMode(value) { + if (__DEV__ && !["ios"].includes(_reactNative.Platform.OS)) { + console.warn("ignoreSilentSwitchMode is not supported on this platform, it wont have any effect"); + } + this.player.ignoreSilentSwitchMode = value; + } + + // Play In Background + get playInBackground() { + return this.player.playInBackground; + } + set playInBackground(value) { + this.player.playInBackground = value; + } + + // Play When Inactive + get playWhenInactive() { + return this.player.playWhenInactive; + } + set playWhenInactive(value) { + this.player.playWhenInactive = value; + } + + // Is Playing + get isPlaying() { + return this.player.isPlaying; + } + get showNotificationControls() { + return this.player.showNotificationControls; + } + set showNotificationControls(value) { + this.player.showNotificationControls = value; + } + async initialize() { + await this.wrapPromise(this.player.initialize()); + this.updateMemorySize(); + } + async preload() { + await this.wrapPromise(this.player.preload()); + this.updateMemorySize(); + } + + /** + * Releases the player's native resources and releases native state. + * After calling this method, the player is no longer usable. + * Accessing any properties or methods of the player after calling this method will throw an error. + * If you want to clean player resource use `replaceSourceAsync` with `null` instead. + */ + release() { + this.__destroy(); + } + play() { + try { + this.player.play(); + } catch (error) { + this.throwError(error); + } + } + pause() { + try { + this.player.pause(); + } catch (error) { + this.throwError(error); + } + } + seekBy(time) { + try { + this.player.seekBy(time); + } catch (error) { + this.throwError(error); + } + } + seekTo(time) { + try { + this.player.seekTo(time); + } catch (error) { + this.throwError(error); + } + } + async replaceSourceAsync(source) { + this.updateMemorySize(); + await this.wrapPromise(this.player.replaceSourceAsync(source === null ? null : (0, _sourceFactory.createSource)(source))); + this.updateMemorySize(); + } + + // Text Track Management + getAvailableTextTracks() { + try { + return this.player.getAvailableTextTracks(); + } catch (error) { + this.throwError(error); + return []; + } + } + selectTextTrack(textTrack) { + try { + this.player.selectTextTrack(textTrack); + } catch (error) { + this.throwError(error); + } + } + + // Selected Text Track + get selectedTrack() { + return this.player.selectedTrack; + } +} +exports.VideoPlayer = VideoPlayer; +//# sourceMappingURL=VideoPlayer.js.map \ No newline at end of file diff --git a/lib/commonjs/core/VideoPlayer.js.map b/lib/commonjs/core/VideoPlayer.js.map new file mode 100644 index 00000000..b2b874fa --- /dev/null +++ b/lib/commonjs/core/VideoPlayer.js.map @@ -0,0 +1 @@ +{"version":3,"names":["_reactNative","require","_reactNativeNitroModules","_VideoError","_playerFactory","_sourceFactory","_VideoPlayerEvents","VideoPlayer","VideoPlayerEvents","player","_player","undefined","VideoRuntimeError","constructor","source","hybridSource","createSource","createPlayer","eventEmitter","__destroy","clearAllEvents","release","error","console","updateMemorySize","_releaseTimeout","clearTimeout","setTimeout","__getNativePlayer","throwError","parsedError","tryParseNativeVideoError","triggerJSEvent","NitroModules","wrapPromise","promise","Promise","resolve","reject","then","catch","status","duration","volume","value","currentTime","muted","loop","rate","mixAudioMode","ignoreSilentSwitchMode","__DEV__","includes","Platform","OS","warn","playInBackground","playWhenInactive","isPlaying","showNotificationControls","initialize","preload","play","pause","seekBy","time","seekTo","replaceSourceAsync","getAvailableTextTracks","selectTextTrack","textTrack","selectedTrack","exports"],"sourceRoot":"../../../src","sources":["core/VideoPlayer.ts"],"mappings":";;;;;;AAAA,IAAAA,YAAA,GAAAC,OAAA;AACA,IAAAC,wBAAA,GAAAD,OAAA;AAQA,IAAAE,WAAA,GAAAF,OAAA;AAMA,IAAAG,cAAA,GAAAH,OAAA;AACA,IAAAI,cAAA,GAAAJ,OAAA;AACA,IAAAK,kBAAA,GAAAL,OAAA;AAEA,MAAMM,WAAW,SAASC,oCAAiB,CAA4B;EAIrE,IAAcC,MAAMA,CAAA,EAAoB;IACtC,IAAI,IAAI,CAACC,OAAO,KAAKC,SAAS,EAAE;MAC9B,MAAM,IAAIC,6BAAiB,CACzB,iBAAiB,EACjB,6CACF,CAAC;IACH;IAEA,OAAO,IAAI,CAACF,OAAO;EACrB;EAEAG,WAAWA,CAACC,MAAqD,EAAE;IACjE,MAAMC,YAAY,GAAG,IAAAC,2BAAY,EAACF,MAAM,CAAC;IACzC,MAAML,MAAM,GAAG,IAAAQ,2BAAY,EAACF,YAAY,CAAC;;IAEzC;IACA,KAAK,CAACN,MAAM,CAACS,YAAY,CAAC;IAC1B,IAAI,CAACR,OAAO,GAAGD,MAAM;EACvB;;EAEA;AACF;AACA;AACA;EACEU,SAASA,CAAA,EAAG;IACV,IAAI,IAAI,CAACT,OAAO,KAAKC,SAAS,EAAE;IAEhC,IAAI,CAACS,cAAc,CAAC,CAAC;IAErB,IAAI;MACF,IAAI,CAACX,MAAM,CAACY,OAAO,CAAC,CAAC;IACvB,CAAC,CAAC,OAAOC,KAAK,EAAE;MACd;MACAC,OAAO,CAACD,KAAK,CAAC,2CAA2C,EAAEA,KAAK,CAAC;IACnE;;IAEA;IACA;IACA;IACA,IAAI,CAACE,gBAAgB,CAAC,CAAC;;IAEvB;IACA,IAAI,IAAI,CAACC,eAAe,KAAKd,SAAS,EAAE;MACtCe,YAAY,CAAC,IAAI,CAACD,eAAe,CAAC;IACpC;IAEA,IAAI,CAACA,eAAe,GAAGE,UAAU,CAAC,MAAM;MACtC,IAAI,CAACjB,OAAO,GAAGC,SAAS;MACxB,IAAI,CAACc,eAAe,GAAGd,SAAS;IAClC,CAAC,EAAE,IAAI,CAAC;EACV;;EAEA;AACF;AACA;AACA;AACA;EACEiB,iBAAiBA,CAAA,EAAG;IAClB,OAAO,IAAI,CAACnB,MAAM;EACpB;;EAEA;AACF;AACA;AACA;EACUoB,UAAUA,CAACP,KAAc,EAAE;IACjC,MAAMQ,WAAW,GAAG,IAAAC,oCAAwB,EAACT,KAAK,CAAC;IAEnD,IACEQ,WAAW,YAAYlB,6BAAiB,IACxC,IAAI,CAACoB,cAAc,CAAC,SAAS,EAAEF,WAAgC,CAAC,EAChE;MACA;MACA;IACF;IAEA,MAAMA,WAAW;EACnB;;EAEA;AACF;AACA;AACA;EACUN,gBAAgBA,CAAA,EAAG;IACzBS,qCAAY,CAACT,gBAAgB,CAAC,IAAI,CAACf,MAAM,CAAC;IAC1CwB,qCAAY,CAACT,gBAAgB,CAAC,IAAI,CAACf,MAAM,CAACK,MAAM,CAAC;EACnD;;EAEA;AACF;AACA;AACA;EACUoB,WAAWA,CAAIC,OAAmB,EAAE;IAC1C,OAAO,IAAIC,OAAO,CAAI,CAACC,OAAO,EAAEC,MAAM,KAAK;MACzCH,OAAO,CAACI,IAAI,CAACF,OAAO,CAAC,CAACG,KAAK,CAAElB,KAAK,IAAK;QACrCgB,MAAM,CAAC,IAAI,CAACT,UAAU,CAACP,KAAK,CAAC,CAAC;MAChC,CAAC,CAAC;IACJ,CAAC,CAAC;EACJ;;EAEA;EACA,IAAIR,MAAMA,CAAA,EAAsB;IAC9B,OAAO,IAAI,CAACL,MAAM,CAACK,MAAM;EAC3B;;EAEA;EACA,IAAI2B,MAAMA,CAAA,EAAsB;IAC9B,OAAO,IAAI,CAAChC,MAAM,CAACgC,MAAM;EAC3B;;EAEA;EACA,IAAIC,QAAQA,CAAA,EAAW;IACrB,OAAO,IAAI,CAACjC,MAAM,CAACiC,QAAQ;EAC7B;;EAEA;EACA,IAAIC,MAAMA,CAAA,EAAW;IACnB,OAAO,IAAI,CAAClC,MAAM,CAACkC,MAAM;EAC3B;EAEA,IAAIA,MAAMA,CAACC,KAAa,EAAE;IACxB,IAAI,CAACnC,MAAM,CAACkC,MAAM,GAAGC,KAAK;EAC5B;;EAEA;EACA,IAAIC,WAAWA,CAAA,EAAW;IACxB,OAAO,IAAI,CAACpC,MAAM,CAACoC,WAAW;EAChC;EAEA,IAAIA,WAAWA,CAACD,KAAa,EAAE;IAC7B,IAAI,CAACnC,MAAM,CAACoC,WAAW,GAAGD,KAAK;EACjC;;EAEA;EACA,IAAIE,KAAKA,CAAA,EAAY;IACnB,OAAO,IAAI,CAACrC,MAAM,CAACqC,KAAK;EAC1B;EAEA,IAAIA,KAAKA,CAACF,KAAc,EAAE;IACxB,IAAI,CAACnC,MAAM,CAACqC,KAAK,GAAGF,KAAK;EAC3B;;EAEA;EACA,IAAIG,IAAIA,CAAA,EAAY;IAClB,OAAO,IAAI,CAACtC,MAAM,CAACsC,IAAI;EACzB;EAEA,IAAIA,IAAIA,CAACH,KAAc,EAAE;IACvB,IAAI,CAACnC,MAAM,CAACsC,IAAI,GAAGH,KAAK;EAC1B;;EAEA;EACA,IAAII,IAAIA,CAAA,EAAW;IACjB,OAAO,IAAI,CAACvC,MAAM,CAACuC,IAAI;EACzB;EAEA,IAAIA,IAAIA,CAACJ,KAAa,EAAE;IACtB,IAAI,CAACnC,MAAM,CAACuC,IAAI,GAAGJ,KAAK;EAC1B;;EAEA;EACA,IAAIK,YAAYA,CAAA,EAAiB;IAC/B,OAAO,IAAI,CAACxC,MAAM,CAACwC,YAAY;EACjC;EAEA,IAAIA,YAAYA,CAACL,KAAmB,EAAE;IACpC,IAAI,CAACnC,MAAM,CAACwC,YAAY,GAAGL,KAAK;EAClC;;EAEA;EACA,IAAIM,sBAAsBA,CAAA,EAA2B;IACnD,OAAO,IAAI,CAACzC,MAAM,CAACyC,sBAAsB;EAC3C;EAEA,IAAIA,sBAAsBA,CAACN,KAA6B,EAAE;IACxD,IAAIO,OAAO,IAAI,CAAC,CAAC,KAAK,CAAC,CAACC,QAAQ,CAACC,qBAAQ,CAACC,EAAE,CAAC,EAAE;MAC7C/B,OAAO,CAACgC,IAAI,CACV,mFACF,CAAC;IACH;IAEA,IAAI,CAAC9C,MAAM,CAACyC,sBAAsB,GAAGN,KAAK;EAC5C;;EAEA;EACA,IAAIY,gBAAgBA,CAAA,EAAY;IAC9B,OAAO,IAAI,CAAC/C,MAAM,CAAC+C,gBAAgB;EACrC;EAEA,IAAIA,gBAAgBA,CAACZ,KAAc,EAAE;IACnC,IAAI,CAACnC,MAAM,CAAC+C,gBAAgB,GAAGZ,KAAK;EACtC;;EAEA;EACA,IAAIa,gBAAgBA,CAAA,EAAY;IAC9B,OAAO,IAAI,CAAChD,MAAM,CAACgD,gBAAgB;EACrC;EAEA,IAAIA,gBAAgBA,CAACb,KAAc,EAAE;IACnC,IAAI,CAACnC,MAAM,CAACgD,gBAAgB,GAAGb,KAAK;EACtC;;EAEA;EACA,IAAIc,SAASA,CAAA,EAAY;IACvB,OAAO,IAAI,CAACjD,MAAM,CAACiD,SAAS;EAC9B;EAEA,IAAIC,wBAAwBA,CAAA,EAAY;IACtC,OAAO,IAAI,CAAClD,MAAM,CAACkD,wBAAwB;EAC7C;EAEA,IAAIA,wBAAwBA,CAACf,KAAc,EAAE;IAC3C,IAAI,CAACnC,MAAM,CAACkD,wBAAwB,GAAGf,KAAK;EAC9C;EAEA,MAAMgB,UAAUA,CAAA,EAAkB;IAChC,MAAM,IAAI,CAAC1B,WAAW,CAAC,IAAI,CAACzB,MAAM,CAACmD,UAAU,CAAC,CAAC,CAAC;IAEhD,IAAI,CAACpC,gBAAgB,CAAC,CAAC;EACzB;EAEA,MAAMqC,OAAOA,CAAA,EAAkB;IAC7B,MAAM,IAAI,CAAC3B,WAAW,CAAC,IAAI,CAACzB,MAAM,CAACoD,OAAO,CAAC,CAAC,CAAC;IAE7C,IAAI,CAACrC,gBAAgB,CAAC,CAAC;EACzB;;EAEA;AACF;AACA;AACA;AACA;AACA;EACEH,OAAOA,CAAA,EAAS;IACd,IAAI,CAACF,SAAS,CAAC,CAAC;EAClB;EAEA2C,IAAIA,CAAA,EAAS;IACX,IAAI;MACF,IAAI,CAACrD,MAAM,CAACqD,IAAI,CAAC,CAAC;IACpB,CAAC,CAAC,OAAOxC,KAAK,EAAE;MACd,IAAI,CAACO,UAAU,CAACP,KAAK,CAAC;IACxB;EACF;EAEAyC,KAAKA,CAAA,EAAS;IACZ,IAAI;MACF,IAAI,CAACtD,MAAM,CAACsD,KAAK,CAAC,CAAC;IACrB,CAAC,CAAC,OAAOzC,KAAK,EAAE;MACd,IAAI,CAACO,UAAU,CAACP,KAAK,CAAC;IACxB;EACF;EAEA0C,MAAMA,CAACC,IAAY,EAAQ;IACzB,IAAI;MACF,IAAI,CAACxD,MAAM,CAACuD,MAAM,CAACC,IAAI,CAAC;IAC1B,CAAC,CAAC,OAAO3C,KAAK,EAAE;MACd,IAAI,CAACO,UAAU,CAACP,KAAK,CAAC;IACxB;EACF;EAEA4C,MAAMA,CAACD,IAAY,EAAQ;IACzB,IAAI;MACF,IAAI,CAACxD,MAAM,CAACyD,MAAM,CAACD,IAAI,CAAC;IAC1B,CAAC,CAAC,OAAO3C,KAAK,EAAE;MACd,IAAI,CAACO,UAAU,CAACP,KAAK,CAAC;IACxB;EACF;EAEA,MAAM6C,kBAAkBA,CACtBrD,MAIQ,EACO;IACf,IAAI,CAACU,gBAAgB,CAAC,CAAC;IAEvB,MAAM,IAAI,CAACU,WAAW,CACpB,IAAI,CAACzB,MAAM,CAAC0D,kBAAkB,CAC5BrD,MAAM,KAAK,IAAI,GAAG,IAAI,GAAG,IAAAE,2BAAY,EAACF,MAAM,CAC9C,CACF,CAAC;IAED,IAAI,CAACU,gBAAgB,CAAC,CAAC;EACzB;;EAEA;EACA4C,sBAAsBA,CAAA,EAAgB;IACpC,IAAI;MACF,OAAO,IAAI,CAAC3D,MAAM,CAAC2D,sBAAsB,CAAC,CAAC;IAC7C,CAAC,CAAC,OAAO9C,KAAK,EAAE;MACd,IAAI,CAACO,UAAU,CAACP,KAAK,CAAC;MACtB,OAAO,EAAE;IACX;EACF;EAEA+C,eAAeA,CAACC,SAA2B,EAAQ;IACjD,IAAI;MACF,IAAI,CAAC7D,MAAM,CAAC4D,eAAe,CAACC,SAAS,CAAC;IACxC,CAAC,CAAC,OAAOhD,KAAK,EAAE;MACd,IAAI,CAACO,UAAU,CAACP,KAAK,CAAC;IACxB;EACF;;EAEA;EACA,IAAIiD,aAAaA,CAAA,EAA0B;IACzC,OAAO,IAAI,CAAC9D,MAAM,CAAC8D,aAAa;EAClC;AAEF;AAACC,OAAA,CAAAjE,WAAA,GAAAA,WAAA","ignoreList":[]} diff --git a/lib/commonjs/core/VideoPlayer.web.js b/lib/commonjs/core/VideoPlayer.web.js new file mode 100644 index 00000000..0381b673 --- /dev/null +++ b/lib/commonjs/core/VideoPlayer.web.js @@ -0,0 +1,274 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.VideoPlayer = void 0; +var _VideoPlayerEvents = require("./events/VideoPlayerEvents"); +var _MediaSession = require("./web/MediaSession.js"); +var _WebEventEmitter = require("./web/WebEventEmitter.js"); +class VideoPlayer extends _VideoPlayerEvents.VideoPlayerEvents { + _store = null; + mediaSession = null; + /** + * Creates a detached