diff --git a/.gitignore b/.gitignore index 86f4745b6..28ff76f7c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies node_modules/ +!node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java # Expo .expo/ @@ -40,11 +41,14 @@ release_announcement.md ALPHA_BUILD_2_ANNOUNCEMENT.md CHANGELOG.md .env.local -android/ +# Android build artifacts (but keep source files) +android/app/build/ +android/build/ +android/.gradle/ +android/app/libs/*.aar +!android/app/libs/lib-decoder-ffmpeg-release.aar HEATING_OPTIMIZATIONS.md - -android -sliderreadme.md +# sliderreadme.md .cursor/mcp.json local-scrapers-repo worki.json @@ -55,5 +59,7 @@ src/screens/xavio.md /nuvio-providers /KSPlayer /exobase -ffmpegreadme.md +# ffmpegreadme.md toast.md +ffmpegreadme.md +sliderreadme.md diff --git a/android/app/libs/lib-decoder-ffmpeg-release.aar b/android/app/libs/lib-decoder-ffmpeg-release.aar new file mode 100644 index 000000000..2ee1cee72 Binary files /dev/null and b/android/app/libs/lib-decoder-ffmpeg-release.aar differ diff --git a/assets/AppIcons/android/mipmap-hdpi/ic_launcher.png b/assets/AppIcons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..fc396770a Binary files /dev/null and b/assets/AppIcons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/assets/AppIcons/android/mipmap-mdpi/ic_launcher.png b/assets/AppIcons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..02d4de67f Binary files /dev/null and b/assets/AppIcons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/assets/AppIcons/android/mipmap-xhdpi/ic_launcher.png b/assets/AppIcons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..41b56cf19 Binary files /dev/null and b/assets/AppIcons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/assets/AppIcons/android/mipmap-xxhdpi/ic_launcher.png b/assets/AppIcons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..570413cca Binary files /dev/null and b/assets/AppIcons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/assets/AppIcons/android/mipmap-xxxhdpi/ic_launcher.png b/assets/AppIcons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..a101d4a96 Binary files /dev/null and b/assets/AppIcons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/bootsplash/android/drawable-hdpi/bootsplash_logo.png b/assets/bootsplash/android/drawable-hdpi/bootsplash_logo.png new file mode 100644 index 000000000..bd6895d78 Binary files /dev/null and b/assets/bootsplash/android/drawable-hdpi/bootsplash_logo.png differ diff --git a/assets/bootsplash/android/drawable-mdpi/bootsplash_logo.png b/assets/bootsplash/android/drawable-mdpi/bootsplash_logo.png new file mode 100644 index 000000000..b6d09cc16 Binary files /dev/null and b/assets/bootsplash/android/drawable-mdpi/bootsplash_logo.png differ diff --git a/assets/bootsplash/android/drawable-xhdpi/bootsplash_logo.png b/assets/bootsplash/android/drawable-xhdpi/bootsplash_logo.png new file mode 100644 index 000000000..45de63139 Binary files /dev/null and b/assets/bootsplash/android/drawable-xhdpi/bootsplash_logo.png differ diff --git a/assets/bootsplash/android/drawable-xxhdpi/bootsplash_logo.png b/assets/bootsplash/android/drawable-xxhdpi/bootsplash_logo.png new file mode 100644 index 000000000..850078ad3 Binary files /dev/null and b/assets/bootsplash/android/drawable-xxhdpi/bootsplash_logo.png differ diff --git a/assets/bootsplash/android/drawable-xxxhdpi/bootsplash_logo.png b/assets/bootsplash/android/drawable-xxxhdpi/bootsplash_logo.png new file mode 100644 index 000000000..5d38cd59d Binary files /dev/null and b/assets/bootsplash/android/drawable-xxxhdpi/bootsplash_logo.png differ diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj index 0f31caf44..a248d5aa5 100644 --- a/ios/Nuvio.xcodeproj/project.pbxproj +++ b/ios/Nuvio.xcodeproj/project.pbxproj @@ -467,7 +467,7 @@ ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; - PRODUCT_NAME = Nuvio; + PRODUCT_NAME = "Nuvio"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -501,7 +501,7 @@ ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; - PRODUCT_NAME = Nuvio; + PRODUCT_NAME = "Nuvio"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/ios/Nuvio/NuvioRelease.entitlements b/ios/Nuvio/NuvioRelease.entitlements index 0c67376eb..a0bc443fa 100644 --- a/ios/Nuvio/NuvioRelease.entitlements +++ b/ios/Nuvio/NuvioRelease.entitlements @@ -1,5 +1,10 @@ - - + + aps-environment + development + com.apple.developer.associated-domains + + + \ No newline at end of file diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java new file mode 100644 index 000000000..bea60c098 --- /dev/null +++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -0,0 +1,2740 @@ +package com.brentvatne.exoplayer; + +import static androidx.media3.common.C.CONTENT_TYPE_DASH; +import static androidx.media3.common.C.CONTENT_TYPE_HLS; +import static androidx.media3.common.C.CONTENT_TYPE_OTHER; +import static androidx.media3.common.C.CONTENT_TYPE_RTSP; +import static androidx.media3.common.C.CONTENT_TYPE_SS; +import static androidx.media3.common.C.TIME_END_OF_SOURCE; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.ActivityManager; +import android.app.PictureInPictureParams; +import android.app.RemoteAction; +import android.app.AlertDialog; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.Message; +import android.text.TextUtils; +import android.view.View; +import android.view.ViewGroup; +import android.view.accessibility.CaptioningManager; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.media3.common.AudioAttributes; +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MediaMetadata; +import androidx.media3.common.Metadata; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.PlaybackParameters; +import androidx.media3.common.Player; +import androidx.media3.common.StreamKey; +import androidx.media3.common.Timeline; +import androidx.media3.common.TrackGroup; +import androidx.media3.common.TrackSelectionOverride; +import androidx.media3.common.Tracks; +import androidx.media3.common.text.CueGroup; +import androidx.media3.common.util.Util; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.HttpDataSource; +import androidx.media3.exoplayer.DefaultLoadControl; +import androidx.media3.exoplayer.DefaultRenderersFactory; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.dash.DashMediaSource; +import androidx.media3.exoplayer.dash.DashUtil; +import androidx.media3.exoplayer.dash.DefaultDashChunkSource; +import androidx.media3.exoplayer.dash.manifest.AdaptationSet; +import androidx.media3.exoplayer.dash.manifest.DashManifest; +import androidx.media3.exoplayer.dash.manifest.Period; +import androidx.media3.exoplayer.dash.manifest.Representation; +import androidx.media3.exoplayer.drm.DefaultDrmSessionManager; +import androidx.media3.exoplayer.drm.DefaultDrmSessionManagerProvider; +import androidx.media3.exoplayer.drm.DrmSessionEventListener; +import androidx.media3.exoplayer.drm.DrmSessionManager; +import androidx.media3.exoplayer.drm.DrmSessionManagerProvider; +import androidx.media3.exoplayer.drm.FrameworkMediaDrm; +import androidx.media3.exoplayer.drm.HttpMediaDrmCallback; +import androidx.media3.exoplayer.drm.UnsupportedDrmException; +import androidx.media3.exoplayer.hls.HlsMediaSource; +import androidx.media3.exoplayer.ima.ImaAdsLoader; +import androidx.media3.exoplayer.mediacodec.MediaCodecInfo; +import androidx.media3.exoplayer.mediacodec.MediaCodecUtil; +import androidx.media3.exoplayer.rtsp.RtspMediaSource; +import androidx.media3.exoplayer.smoothstreaming.DefaultSsChunkSource; +import androidx.media3.exoplayer.smoothstreaming.SsMediaSource; +import androidx.media3.exoplayer.source.ClippingMediaSource; +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.source.MergingMediaSource; +import androidx.media3.exoplayer.source.ProgressiveMediaSource; +import androidx.media3.exoplayer.source.TrackGroupArray; +import androidx.media3.exoplayer.source.ads.AdsMediaSource; +import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection; +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; +import androidx.media3.exoplayer.trackselection.ExoTrackSelection; +import androidx.media3.exoplayer.trackselection.MappingTrackSelector; +import androidx.media3.exoplayer.trackselection.TrackSelection; +import androidx.media3.exoplayer.trackselection.TrackSelectionArray; +import androidx.media3.exoplayer.upstream.BandwidthMeter; +import androidx.media3.exoplayer.upstream.CmcdConfiguration; +import androidx.media3.exoplayer.upstream.DefaultAllocator; +import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter; +import androidx.media3.exoplayer.util.EventLogger; +import androidx.media3.extractor.metadata.emsg.EventMessage; +import androidx.media3.extractor.metadata.id3.Id3Frame; +import androidx.media3.extractor.metadata.id3.TextInformationFrame; +import androidx.media3.session.MediaSessionService; + +import com.brentvatne.common.api.AdsProps; +import com.brentvatne.common.api.BufferConfig; +import com.brentvatne.common.api.BufferingStrategy; +import com.brentvatne.common.api.ControlsConfig; +import com.brentvatne.common.api.DRMProps; +import com.brentvatne.common.api.ResizeMode; +import com.brentvatne.common.api.SideLoadedTextTrack; +import com.brentvatne.common.api.Source; +import com.brentvatne.common.api.SubtitleStyle; +import com.brentvatne.common.api.TimedMetadata; +import com.brentvatne.common.api.Track; +import com.brentvatne.common.api.VideoTrack; +import com.brentvatne.common.react.VideoEventEmitter; +import com.brentvatne.common.toolbox.DebugLog; +import com.brentvatne.common.toolbox.ReactBridgeUtils; +import com.brentvatne.react.BuildConfig; +import com.brentvatne.react.R; +import com.brentvatne.react.ReactNativeVideoManager; +import com.brentvatne.receiver.AudioBecomingNoisyReceiver; +import com.brentvatne.receiver.BecomingNoisyListener; +import com.brentvatne.receiver.PictureInPictureReceiver; +import com.facebook.react.bridge.LifecycleEventListener; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.UiThreadUtil; +import com.facebook.react.uimanager.ThemedReactContext; +import com.google.ads.interactivemedia.v3.api.AdError; +import com.google.ads.interactivemedia.v3.api.AdErrorEvent; +import com.google.ads.interactivemedia.v3.api.AdEvent; +import com.google.ads.interactivemedia.v3.api.ImaSdkFactory; +import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; +import com.google.common.collect.ImmutableList; + +import java.net.CookieHandler; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +@SuppressLint("ViewConstructor") +public class ReactExoplayerView extends FrameLayout implements + LifecycleEventListener, + Player.Listener, + BandwidthMeter.EventListener, + BecomingNoisyListener, + DrmSessionEventListener, + AdEvent.AdEventListener, + AdErrorEvent.AdErrorListener { + + public static final double DEFAULT_MAX_HEAP_ALLOCATION_PERCENT = 1; + public static final double DEFAULT_MIN_BUFFER_MEMORY_RESERVE = 0; + + private static final String TAG = "ReactExoplayerView"; + + private static final CookieManager DEFAULT_COOKIE_MANAGER; + private static final int SHOW_PROGRESS = 1; + + static { + DEFAULT_COOKIE_MANAGER = new CookieManager(); + DEFAULT_COOKIE_MANAGER.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER); + } + + protected final VideoEventEmitter eventEmitter; + private final ReactExoplayerConfig config; + private DefaultBandwidthMeter bandwidthMeter; + private Player.Listener eventListener; + + private ExoPlayerView exoPlayerView; + private FullScreenPlayerView fullScreenPlayerView; + private ImaAdsLoader adsLoader; + + private DataSource.Factory mediaDataSourceFactory; + private ExoPlayer player; + private DefaultTrackSelector trackSelector; + private boolean playerNeedsSource; + private ServiceConnection playbackServiceConnection; + private PlaybackServiceBinder playbackServiceBinder; + + // logger to be enable by props + private EventLogger debugEventLogger = null; + private boolean enableDebug = false; + private static final String TAG_EVENT_LOGGER = "RNVExoplayer"; + + private int resumeWindow; + private long resumePosition; + private boolean loadVideoStarted; + private boolean isFullscreen; + private boolean isInBackground; + private boolean isPaused; + private boolean isBuffering; + private boolean muted = false; + public boolean enterPictureInPictureOnLeave = false; + private PictureInPictureParams.Builder pictureInPictureParamsBuilder; + private boolean hasAudioFocus = false; + private float rate = 1f; + private AudioOutput audioOutput = AudioOutput.SPEAKER; + private float audioVolume = 1f; + private int maxBitRate = 0; + private boolean hasDrmFailed = false; + private boolean isUsingContentResolution = false; + private boolean selectTrackWhenReady = false; + private final Handler mainHandler; + private Runnable mainRunnable; + private Runnable pipListenerUnsubscribe; + private boolean useCache = false; + private boolean disableCache = false; + private ControlsConfig controlsConfig = new ControlsConfig(); + private ArrayList rootViewChildrenOriginalVisibility = new ArrayList(); + + /* + * When user is seeking first called is on onPositionDiscontinuity -> DISCONTINUITY_REASON_SEEK + * Then we set if to false when playback is back in onIsPlayingChanged -> true + */ + private boolean isSeeking = false; + private long seekPosition = -1; + + // Props from React + private Source source = new Source(); + private boolean repeat; + private String audioTrackType; + private String audioTrackValue; + private String videoTrackType; + private String videoTrackValue; + private String textTrackType = "disabled"; + private String textTrackValue; + private boolean disableFocus; + private boolean focusable = true; + private BufferingStrategy.BufferingStrategyEnum bufferingStrategy; + private boolean disableDisconnectError; + private boolean preventsDisplaySleepDuringVideoPlayback = true; + private float mProgressUpdateInterval = 250.0f; + protected boolean playInBackground = false; + private boolean mReportBandwidth = false; + private boolean controls = false; + + private boolean showNotificationControls = false; + // \ End props + + // React + private final ThemedReactContext themedReactContext; + private final AudioManager audioManager; + private final AudioBecomingNoisyReceiver audioBecomingNoisyReceiver; + private final PictureInPictureReceiver pictureInPictureReceiver; + private final AudioManager.OnAudioFocusChangeListener audioFocusChangeListener; + + // store last progress event values to avoid sending unnecessary messages + private long lastPos = -1; + private long lastBufferDuration = -1; + private long lastDuration = -1; + + private boolean viewHasDropped = false; + private int selectedSpeedIndex = 1; // Default is 1.0x + + private final String instanceId = String.valueOf(UUID.randomUUID()); + + private CmcdConfiguration.Factory cmcdConfigurationFactory; + + public void setCmcdConfigurationFactory(CmcdConfiguration.Factory factory) { + this.cmcdConfigurationFactory = factory; + } + + private void updateProgress() { + if (player != null) { + if (exoPlayerView != null && isPlayingAd() && controls) { + exoPlayerView.hideController(); + } + long bufferedDuration = player.getBufferedPercentage() * player.getDuration() / 100; + long duration = player.getDuration(); + long pos = player.getCurrentPosition(); + if (pos > duration) { + pos = duration; + } + + if (lastPos != pos + || lastBufferDuration != bufferedDuration + || lastDuration != duration) { + lastPos = pos; + lastBufferDuration = bufferedDuration; + lastDuration = duration; + eventEmitter.onVideoProgress.invoke(pos, bufferedDuration, player.getDuration(), getPositionInFirstPeriodMsForCurrentWindow(pos)); + } + } + } + + private final Handler progressHandler = new Handler(Looper.getMainLooper()) { + @Override + public void handleMessage(Message msg) { + if (msg.what == SHOW_PROGRESS) { + updateProgress(); + msg = obtainMessage(SHOW_PROGRESS); + sendMessageDelayed(msg, Math.round(mProgressUpdateInterval)); + } + } + }; + + public double getPositionInFirstPeriodMsForCurrentWindow(long currentPosition) { + Timeline.Window window = new Timeline.Window(); + if(!player.getCurrentTimeline().isEmpty()) { + player.getCurrentTimeline().getWindow(player.getCurrentMediaItemIndex(), window); + } + return window.windowStartTimeMs + currentPosition; + } + + public ReactExoplayerView(ThemedReactContext context, ReactExoplayerConfig config) { + super(context); + this.themedReactContext = context; + this.eventEmitter = new VideoEventEmitter(); + this.config = config; + this.bandwidthMeter = config.getBandwidthMeter(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && pictureInPictureParamsBuilder == null) { + this.pictureInPictureParamsBuilder = new PictureInPictureParams.Builder(); + } + mainHandler = new Handler(); + + createViews(); + + audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + themedReactContext.addLifecycleEventListener(this); + audioBecomingNoisyReceiver = new AudioBecomingNoisyReceiver(themedReactContext); + audioFocusChangeListener = new OnAudioFocusChangedListener(this, themedReactContext); + pictureInPictureReceiver = new PictureInPictureReceiver(this, themedReactContext); + } + + private boolean isPlayingAd() { + return player != null && player.isPlayingAd(); + } + + private void createViews() { + if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) { + CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER); + } + + LayoutParams layoutParams = new LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT); + exoPlayerView = new ExoPlayerView(getContext()); + exoPlayerView.addOnLayoutChangeListener( (View v, int l, int t, int r, int b, int ol, int ot, int or, int ob) -> + PictureInPictureUtil.applySourceRectHint(themedReactContext, pictureInPictureParamsBuilder, exoPlayerView) + ); + exoPlayerView.setLayoutParams(layoutParams); + addView(exoPlayerView, 0, layoutParams); + + exoPlayerView.setFocusable(this.focusable); + } + + @Override + protected void onDetachedFromWindow() { + cleanupPlaybackService(); + super.onDetachedFromWindow(); + } + + // LifecycleEventListener implementation + @Override + public void onHostResume() { + if (!playInBackground || !isInBackground) { + setPlayWhenReady(!isPaused); + } + isInBackground = false; + } + + @Override + public void onHostPause() { + isInBackground = true; + Activity activity = themedReactContext.getCurrentActivity(); + boolean isInPictureInPicture = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null && activity.isInPictureInPictureMode(); + boolean isInMultiWindowMode = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null && activity.isInMultiWindowMode(); + if (playInBackground || isInPictureInPicture || isInMultiWindowMode) { + return; + } + setPlayWhenReady(false); + } + + @Override + public void onHostDestroy() { + cleanUpResources(); + } + + public void cleanUpResources() { + stopPlayback(); + themedReactContext.removeLifecycleEventListener(this); + releasePlayer(); + viewHasDropped = true; + } + + //BandwidthMeter.EventListener implementation + @Override + public void onBandwidthSample(int elapsedMs, long bytes, long bitrate) { + if (mReportBandwidth) { + if (player == null) { + eventEmitter.onVideoBandwidthUpdate.invoke(bitrate, 0, 0, null); + } else { + Format videoFormat = player.getVideoFormat(); + boolean isRotatedContent = videoFormat != null && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270); + int width = videoFormat != null ? (isRotatedContent ? videoFormat.height : videoFormat.width) : 0; + int height = videoFormat != null ? (isRotatedContent ? videoFormat.width : videoFormat.height) : 0; + String trackId = videoFormat != null ? videoFormat.id : null; + eventEmitter.onVideoBandwidthUpdate.invoke(bitrate, height, width, trackId); + } + } + } + + // Internal methods + + /** + * Toggling the visibility of the player control view + */ + private void togglePlayerControlVisibility() { + if (player == null) return; + if (exoPlayerView.isControllerVisible()) { + exoPlayerView.hideController(); + } else { + exoPlayerView.showController(); + } + } + + private void initializePlayerControl() { + exoPlayerView.setPlayer(player); + + exoPlayerView.setControllerVisibilityListener(visibility -> { + boolean isVisible = visibility == View.VISIBLE; + eventEmitter.onControlsVisibilityChange.invoke(isVisible); + }); + + exoPlayerView.setFullscreenButtonClickListener(isFullscreen -> { + setFullscreen(!this.isFullscreen); + }); + + updateControllerConfig(); + } + + private void updateControllerConfig() { + if (exoPlayerView == null) return; + + exoPlayerView.setControllerShowTimeoutMs(5000); + + exoPlayerView.setControllerAutoShow(true); + exoPlayerView.setControllerHideOnTouch(true); + + updateControllerVisibility(); + } + + private void updateControllerVisibility() { + if (exoPlayerView == null) return; + + exoPlayerView.setUseController(!controlsConfig.getHideFullscreen()); + } + + private void openSettings() { + AlertDialog.Builder builder = new AlertDialog.Builder(themedReactContext); + builder.setTitle(R.string.settings); + String[] settingsOptions = {themedReactContext.getString(R.string.playback_speed)}; + builder.setItems(settingsOptions, (dialog, which) -> { + if (which == 0) { + showPlaybackSpeedOptions(); + } + }); + builder.show(); + } + + private void showPlaybackSpeedOptions() { + String[] speedOptions = {"0.5x", "1.0x", "1.5x", "2.0x"}; + AlertDialog.Builder builder = new AlertDialog.Builder(themedReactContext); + builder.setTitle(R.string.select_playback_speed); + + builder.setSingleChoiceItems(speedOptions, selectedSpeedIndex, (dialog, which) -> { + selectedSpeedIndex = which; + float speed = 1.0f; + switch (which) { + case 0: + speed = 0.5f; + break; + case 2: + speed = 1.5f; + break; + case 3: + speed = 2.0f; + break; + default: + speed = 1.0f;; + }; + setRateModifier(speed); + }); + builder.show(); + } + + private void addPlayerControl() { + updateControllerConfig(); + } + + /** + * Update the layout + * @param view view needs to update layout + * + * This is a workaround for the open bug in react-native: ... + */ + private void reLayout(View view) { + if (view == null) return; + view.measure(MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.EXACTLY)); + view.layout(view.getLeft(), view.getTop(), view.getMeasuredWidth(), view.getMeasuredHeight()); + } + + private void refreshControlsStyles() { + if (exoPlayerView == null || player == null || !controls) return; + updateControllerVisibility(); + } + + // Note: The following methods for live content and button visibility are no longer needed + // as PlayerView handles controls automatically. Some functionality may need to be + // reimplemented using PlayerView's APIs if custom behavior is required. + + private void reLayoutControls() { + reLayout(exoPlayerView); + } + + /// returns true is adaptive bitrate shall be used + public boolean isUsingVideoABR() { + return videoTrackType == null || "auto".equals(videoTrackType); + } + + public void setDebug(boolean enableDebug) { + this.enableDebug = enableDebug; + refreshDebugState(); + } + + private void refreshDebugState() { + if (player == null) { + return; + } + if (enableDebug) { + debugEventLogger = new EventLogger(TAG_EVENT_LOGGER); + player.addAnalyticsListener(debugEventLogger); + } else if (debugEventLogger != null) { + player.removeAnalyticsListener(debugEventLogger); + debugEventLogger = null; + } + } + + public void setViewType(int viewType) { + exoPlayerView.updateSurfaceView(viewType); + } + + private class RNVLoadControl extends DefaultLoadControl { + private final int availableHeapInBytes; + private final Runtime runtime; + public RNVLoadControl(DefaultAllocator allocator, BufferConfig config) { + super(allocator, + config.getMinBufferMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt() + ? config.getMinBufferMs() + : DefaultLoadControl.DEFAULT_MIN_BUFFER_MS, + config.getMaxBufferMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt() + ? config.getMaxBufferMs() + : DefaultLoadControl.DEFAULT_MAX_BUFFER_MS, + config.getBufferForPlaybackMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt() + ? config.getBufferForPlaybackMs() + : DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS , + config.getBufferForPlaybackAfterRebufferMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt() + ? config.getBufferForPlaybackAfterRebufferMs() + : DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, + -1, + true, + config.getBackBufferDurationMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt() + ? config.getBackBufferDurationMs() + : DefaultLoadControl.DEFAULT_BACK_BUFFER_DURATION_MS, + DefaultLoadControl.DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME); + runtime = Runtime.getRuntime(); + ActivityManager activityManager = (ActivityManager) themedReactContext.getSystemService(ThemedReactContext.ACTIVITY_SERVICE); + double maxHeap = config.getMaxHeapAllocationPercent() != BufferConfig.Companion.getBufferConfigPropUnsetDouble() + ? config.getMaxHeapAllocationPercent() + : DEFAULT_MAX_HEAP_ALLOCATION_PERCENT; + availableHeapInBytes = (int) Math.floor(activityManager.getMemoryClass() * maxHeap * 1024 * 1024); + } + + @Override + public boolean shouldContinueLoading(long playbackPositionUs, long bufferedDurationUs, float playbackSpeed) { + if (bufferingStrategy == BufferingStrategy.BufferingStrategyEnum.DisableBuffering) { + return false; + } else if (bufferingStrategy == BufferingStrategy.BufferingStrategyEnum.DependingOnMemory) { + // The goal of this algorithm is to pause video loading (increasing the buffer) + // when available memory on device become low. + int loadedBytes = getAllocator().getTotalBytesAllocated(); + boolean isHeapReached = availableHeapInBytes > 0 && loadedBytes >= availableHeapInBytes; + if (isHeapReached) { + return false; + } + long usedMemory = runtime.totalMemory() - runtime.freeMemory(); + long freeMemory = runtime.maxMemory() - usedMemory; + double minBufferMemoryReservePercent = source.getBufferConfig().getMinBufferMemoryReservePercent() != BufferConfig.Companion.getBufferConfigPropUnsetDouble() + ? source.getBufferConfig().getMinBufferMemoryReservePercent() + : ReactExoplayerView.DEFAULT_MIN_BUFFER_MEMORY_RESERVE; + long reserveMemory = (long) minBufferMemoryReservePercent * runtime.maxMemory(); + long bufferedMs = bufferedDurationUs / (long) 1000; + if (reserveMemory > freeMemory && bufferedMs > 2000) { + // We don't have enough memory in reserve so we stop buffering to allow other components to use it instead + return false; + } + if (runtime.freeMemory() == 0) { + DebugLog.w(TAG, "Free memory reached 0, forcing garbage collection"); + runtime.gc(); + return false; + } + } + // "default" case or normal case for "DependingOnMemory" + return super.shouldContinueLoading(playbackPositionUs, bufferedDurationUs, playbackSpeed); + } + } + + private void initializePlayer() { + disableCache = ReactNativeVideoManager.Companion.getInstance().shouldDisableCache(source); + + ReactExoplayerView self = this; + Activity activity = themedReactContext.getCurrentActivity(); + // This ensures all props have been settled, to avoid async racing conditions. + Source runningSource = source; + mainRunnable = () -> { + if (viewHasDropped && runningSource == source) { + return; + } + try { + if (runningSource.getUri() == null) { + return; + } + + if (player == null) { + // Initialize core configuration and listeners + initializePlayerCore(self); + pipListenerUnsubscribe = PictureInPictureUtil.addLifecycleEventListener(themedReactContext, this); + PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, this.enterPictureInPictureOnLeave); + } + if (!source.isLocalAssetFile() && !source.isAsset() && source.getBufferConfig().getCacheSize() > 0) { + RNVSimpleCache.INSTANCE.setSimpleCache( + this.getContext(), + source.getBufferConfig().getCacheSize() + ); + useCache = true; + } else { + useCache = false; + } + if (playerNeedsSource) { + // Will force display of shutter view if needed + exoPlayerView.invalidateAspectRatio(); + // DRM session manager creation must be done on a different thread to prevent crashes so we start a new thread + ExecutorService es = Executors.newSingleThreadExecutor(); + es.execute(() -> { + // DRM initialization must run on a different thread + if (viewHasDropped && runningSource == source) { + return; + } + if (activity == null) { + DebugLog.e(TAG, "Failed to initialize Player!, null activity"); + eventEmitter.onVideoError.invoke("Failed to initialize Player!", new Exception("Current Activity is null!"), "1001"); + return; + } + + // Initialize handler to run on the main thread + activity.runOnUiThread(() -> { + if (viewHasDropped && runningSource == source) { + return; + } + try { + // Source initialization must run on the main thread + initializePlayerSource(runningSource); + } catch (Exception ex) { + self.playerNeedsSource = true; + DebugLog.e(TAG, "Failed to initialize Player! 1"); + DebugLog.e(TAG, ex.toString()); + ex.printStackTrace(); + eventEmitter.onVideoError.invoke(ex.toString(), ex, "1001"); + } + }); + }); + } else if (runningSource == source) { + initializePlayerSource(runningSource); + } + } catch (Exception ex) { + self.playerNeedsSource = true; + DebugLog.e(TAG, "Failed to initialize Player! 2"); + DebugLog.e(TAG, ex.toString()); + ex.printStackTrace(); + eventEmitter.onVideoError.invoke(ex.toString(), ex, "1001"); + } + }; + mainHandler.postDelayed(mainRunnable, 1); + } + + public void getCurrentPosition(Promise promise) { + if (player != null) { + float currentPosition = player.getCurrentPosition() / 1000.0f; + promise.resolve(currentPosition); + } else { + promise.reject("PLAYER_NOT_AVAILABLE", "Player is not initialized."); + } + } + + private void initializePlayerCore(ReactExoplayerView self) { + ExoTrackSelection.Factory videoTrackSelectionFactory = new AdaptiveTrackSelection.Factory(); + self.trackSelector = new DefaultTrackSelector(getContext(), videoTrackSelectionFactory); + self.trackSelector.setParameters(trackSelector.buildUponParameters() + .setMaxVideoBitrate(maxBitRate == 0 ? Integer.MAX_VALUE : maxBitRate)); + + DefaultAllocator allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE); + RNVLoadControl loadControl = new RNVLoadControl( + allocator, + source.getBufferConfig() + ); + + long initialBitrate = source.getBufferConfig().getInitialBitrate(); + if (initialBitrate > 0) { + config.setInitialBitrate(initialBitrate); + this.bandwidthMeter = config.getBandwidthMeter(); + } + + DefaultRenderersFactory renderersFactory = + new DefaultRenderersFactory(getContext()) + .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF) + .setEnableDecoderFallback(true) + .forceEnableMediaCodecAsynchronousQueueing(); + + DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory); + if (useCache && !disableCache) { + mediaSourceFactory.setDataSourceFactory(RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true))); + } + + mediaSourceFactory.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView.getPlayerView()); + + player = new ExoPlayer.Builder(getContext(), renderersFactory) + .setTrackSelector(self.trackSelector) + .setBandwidthMeter(bandwidthMeter) + .setLoadControl(loadControl) + .setMediaSourceFactory(mediaSourceFactory) + .build(); + ReactNativeVideoManager.Companion.getInstance().onInstanceCreated(instanceId, player); + refreshDebugState(); + player.addListener(self); + player.setVolume(muted ? 0.f : audioVolume * 1); + exoPlayerView.setPlayer(player); + + audioBecomingNoisyReceiver.setListener(self); + pictureInPictureReceiver.setListener(); + bandwidthMeter.addEventListener(new Handler(), self); + setPlayWhenReady(!isPaused); + playerNeedsSource = true; + + PlaybackParameters params = new PlaybackParameters(rate, 1f); + player.setPlaybackParameters(params); + changeAudioOutput(this.audioOutput); + + if(showNotificationControls) { + setupPlaybackService(); + } + } + + private AdsMediaSource initializeAds(MediaSource videoSource, Source runningSource) { + AdsProps adProps = runningSource.getAdsProps(); + Uri uri = runningSource.getUri(); + if (adProps != null && uri != null) { + Uri adTagUrl = adProps.getAdTagUrl(); + if (adTagUrl != null) { + // Create an AdsLoader. + ImaAdsLoader.Builder imaLoaderBuilder = new ImaAdsLoader + .Builder(themedReactContext) + .setAdEventListener(this) + .setAdErrorListener(this); + + if (adProps.getAdLanguage() != null) { + ImaSdkSettings imaSdkSettings = ImaSdkFactory.getInstance().createImaSdkSettings(); + imaSdkSettings.setLanguage(adProps.getAdLanguage()); + imaLoaderBuilder.setImaSdkSettings(imaSdkSettings); + } + adsLoader = imaLoaderBuilder.build(); + adsLoader.setPlayer(player); + if (adsLoader != null) { + DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory) + .setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView.getPlayerView()); + DataSpec adTagDataSpec = new DataSpec(adTagUrl); + return new AdsMediaSource(videoSource, + adTagDataSpec, + ImmutableList.of(uri, adTagUrl), + mediaSourceFactory, adsLoader, exoPlayerView.getPlayerView()); + } + } + } + + return null; + } + + private DrmSessionManager buildDrmSessionManager(UUID uuid, DRMProps drmProps) throws UnsupportedDrmException { + if (Util.SDK_INT < 18) { + return null; + } + + try { + // First check if there's a custom DRM manager registered through the plugin system + DRMManagerSpec drmManager = ReactNativeVideoManager.Companion.getInstance().getDRMManager(); + if (drmManager == null) { + // If no custom manager is registered, use the default implementation + drmManager = new DRMManager(buildHttpDataSourceFactory(false)); + } + + DrmSessionManager drmSessionManager = drmManager.buildDrmSessionManager(uuid, drmProps); + if (drmSessionManager == null) { + eventEmitter.onVideoError.invoke("Failed to build DRM session manager", new Exception("DRM session manager is null"), "3007"); + } + + // Allow plugins to override the DrmSessionManager + DrmSessionManager overriddenManager = ReactNativeVideoManager.Companion.getInstance().overrideDrmSessionManager(source, drmSessionManager); + return overriddenManager != null ? overriddenManager : drmSessionManager; + } catch (UnsupportedDrmException ex) { + // Unsupported DRM exceptions are handled by the calling method + throw ex; + } catch (Exception ex) { + // Handle any other exception and emit to JS + eventEmitter.onVideoError.invoke(ex.toString(), ex, "3006"); + return null; + } + } + + private void initializePlayerSource(Source runningSource) { + if (runningSource.getUri() == null) { + return; + } + /// init DRM + DrmSessionManager drmSessionManager = initializePlayerDrm(); + if (drmSessionManager == null && runningSource.getDrmProps() != null && runningSource.getDrmProps().getDrmType() != null) { + // Failed to initialize DRM session manager - cannot continue + DebugLog.e(TAG, "Failed to initialize DRM Session Manager Framework!"); + return; + } + // init source to manage ads (external text tracks are now handled in MediaItem) + MediaSource videoSource = buildMediaSource(runningSource.getUri(), + runningSource.getExtension(), + drmSessionManager, + runningSource.getCropStartMs(), + runningSource.getCropEndMs()); + MediaSource mediaSourceWithAds = initializeAds(videoSource, runningSource); + MediaSource mediaSource = Objects.requireNonNullElse(mediaSourceWithAds, videoSource); + + // wait for player to be set + while (player == null) { + try { + wait(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + DebugLog.e(TAG, ex.toString()); + } + } + + boolean haveResumePosition = resumeWindow != C.INDEX_UNSET; + if (haveResumePosition) { + player.seekTo(resumeWindow, resumePosition); + player.setMediaSource(mediaSource, false); + } else if (runningSource.getStartPositionMs() > 0) { + player.setMediaSource(mediaSource, runningSource.getStartPositionMs()); + } else { + player.setMediaSource(mediaSource, true); + } + player.prepare(); + playerNeedsSource = false; + + reLayoutControls(); + + eventEmitter.onVideoLoadStart.invoke(); + loadVideoStarted = true; + + finishPlayerInitialization(); + } + + private DrmSessionManager initializePlayerDrm() { + DrmSessionManager drmSessionManager = null; + DRMProps drmProps = source.getDrmProps(); + // need to realign UUID in DRM Props from source + if (drmProps != null && drmProps.getDrmType() != null) { + UUID uuid = Util.getDrmUuid(drmProps.getDrmType()); + if (uuid != null) { + try { + DebugLog.d(TAG, "drm buildDrmSessionManager"); + drmSessionManager = buildDrmSessionManager(uuid, drmProps); + } catch (UnsupportedDrmException e) { + int errorStringId = Util.SDK_INT < 18 ? R.string.error_drm_not_supported + : (e.reason == UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME + ? R.string.error_drm_unsupported_scheme : R.string.error_drm_unknown); + eventEmitter.onVideoError.invoke(getResources().getString(errorStringId), e, "3003"); + } + } + } + return drmSessionManager; + } + + private void finishPlayerInitialization() { + // Initializing the playerControlView + initializePlayerControl(); + setControls(controls); + applyModifiers(); + } + + private void setupPlaybackService() { + if (!showNotificationControls || player == null) { + return; + } + + playbackServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + playbackServiceBinder = (PlaybackServiceBinder) service; + + try { + Activity currentActivity = themedReactContext.getCurrentActivity(); + if (currentActivity != null) { + playbackServiceBinder.getService().registerPlayer(player, + (Class) currentActivity.getClass()); + } else { + // Handle the case where currentActivity is null + DebugLog.w(TAG, "Could not register ExoPlayer: currentActivity is null"); + } + } catch (Exception e) { + DebugLog.e(TAG, "Could not register ExoPlayer: " + e.getMessage()); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + try { + if (playbackServiceBinder != null) { + playbackServiceBinder.getService().unregisterPlayer(player); + } + } catch (Exception ignored) {} + + playbackServiceBinder = null; + } + + @Override + public void onNullBinding(ComponentName name) { + DebugLog.e(TAG, "Could not register ExoPlayer"); + } + }; + + Intent intent = new Intent(themedReactContext, VideoPlaybackService.class); + intent.setAction(MediaSessionService.SERVICE_INTERFACE); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + themedReactContext.startForegroundService(intent); + } else { + themedReactContext.startService(intent); + } + + int flags; + if (Build.VERSION.SDK_INT >= 29) { + flags = Context.BIND_AUTO_CREATE | Context.BIND_INCLUDE_CAPABILITIES; + } else { + flags = Context.BIND_AUTO_CREATE; + } + + themedReactContext.bindService(intent, playbackServiceConnection, flags); + } + + private void cleanupPlaybackService() { + try { + if(player != null && playbackServiceBinder != null) { + playbackServiceBinder.getService().unregisterPlayer(player); + } + + playbackServiceBinder = null; + + if(playbackServiceConnection != null) { + themedReactContext.unbindService(playbackServiceConnection); + } + } catch(Exception e) { + DebugLog.w(TAG, "Cloud not cleanup playback service"); + } + } + + private MediaSource buildMediaSource(Uri uri, String overrideExtension, DrmSessionManager drmSessionManager, long cropStartMs, long cropEndMs) { + if (uri == null) { + throw new IllegalStateException("Invalid video uri"); + } + int type; + if ("rtsp".equals(overrideExtension)) { + type = CONTENT_TYPE_RTSP; + } else { + type = Util.inferContentType(!TextUtils.isEmpty(overrideExtension) ? "." + overrideExtension + : uri.getLastPathSegment()); + } + config.setDisableDisconnectError(this.disableDisconnectError); + + MediaItem.Builder mediaItemBuilder = new MediaItem.Builder() + .setUri(uri); + + // refresh custom Metadata + MediaMetadata customMetadata = ConfigurationUtils.buildCustomMetadata(source.getMetadata()); + if (customMetadata != null) { + mediaItemBuilder.setMediaMetadata(customMetadata); + } + + // Add external subtitles to MediaItem + List subtitleConfigurations = buildSubtitleConfigurations(); + if (subtitleConfigurations != null) { + mediaItemBuilder.setSubtitleConfigurations(subtitleConfigurations); + } + + if (source.getAdsProps() != null) { + Uri adTagUrl = source.getAdsProps().getAdTagUrl(); + if (adTagUrl != null) { + mediaItemBuilder.setAdsConfiguration( + new MediaItem.AdsConfiguration.Builder(adTagUrl).build() + ); + } + } + + MediaItem.LiveConfiguration.Builder liveConfiguration = ConfigurationUtils.getLiveConfiguration(source.getBufferConfig()); + mediaItemBuilder.setLiveConfiguration(liveConfiguration.build()); + + MediaSource.Factory mediaSourceFactory; + DrmSessionManagerProvider drmProvider; + List streamKeys = new ArrayList<>(); + if (drmSessionManager != null) { + drmProvider = ((_mediaItem) -> drmSessionManager); + } else { + drmProvider = new DefaultDrmSessionManagerProvider(); + } + + + switch (type) { + case CONTENT_TYPE_SS: + if(!BuildConfig.USE_EXOPLAYER_SMOOTH_STREAMING) { + DebugLog.e("Exo Player Exception", "Smooth Streaming is not enabled!"); + throw new IllegalStateException("Smooth Streaming is not enabled!"); + } + + mediaSourceFactory = new SsMediaSource.Factory( + new DefaultSsChunkSource.Factory(mediaDataSourceFactory), + buildDataSourceFactory(false) + ); + break; + case CONTENT_TYPE_DASH: + if(!BuildConfig.USE_EXOPLAYER_DASH) { + DebugLog.e("Exo Player Exception", "DASH is not enabled!"); + throw new IllegalStateException("DASH is not enabled!"); + } + + mediaSourceFactory = new DashMediaSource.Factory( + new DefaultDashChunkSource.Factory(mediaDataSourceFactory), + buildDataSourceFactory(false) + ); + break; + case CONTENT_TYPE_HLS: + if (!BuildConfig.USE_EXOPLAYER_HLS) { + DebugLog.e("Exo Player Exception", "HLS is not enabled!"); + throw new IllegalStateException("HLS is not enabled!"); + } + + DataSource.Factory dataSourceFactory = mediaDataSourceFactory; + + if (useCache && !disableCache) { + dataSourceFactory = RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true)); + } + + mediaSourceFactory = new HlsMediaSource.Factory( + dataSourceFactory + ).setAllowChunklessPreparation(source.getTextTracksAllowChunklessPreparation()); + break; + case CONTENT_TYPE_OTHER: + if ("asset".equals(uri.getScheme())) { + try { + DataSource.Factory assetDataSourceFactory = DataSourceUtil.buildAssetDataSourceFactory(themedReactContext, uri); + mediaSourceFactory = new ProgressiveMediaSource.Factory(assetDataSourceFactory); + } catch (Exception e) { + throw new IllegalStateException("cannot open input file:" + uri); + } + } else if ("file".equals(uri.getScheme()) || + !useCache) { + mediaSourceFactory = new ProgressiveMediaSource.Factory( + mediaDataSourceFactory + ); + } else { + mediaSourceFactory = new ProgressiveMediaSource.Factory( + RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true)) + ); + + } + break; + case CONTENT_TYPE_RTSP: + if (!BuildConfig.USE_EXOPLAYER_RTSP) { + DebugLog.e("Exo Player Exception", "RTSP is not enabled!"); + throw new IllegalStateException("RTSP is not enabled!"); + } + + mediaSourceFactory = new RtspMediaSource.Factory(); + break; + default: { + throw new IllegalStateException("Unsupported type: " + type); + } + } + + if (cmcdConfigurationFactory != null) { + mediaSourceFactory = mediaSourceFactory.setCmcdConfigurationFactory( + cmcdConfigurationFactory::createCmcdConfiguration + ); + } + + mediaSourceFactory = Objects.requireNonNullElse( + ReactNativeVideoManager.Companion.getInstance() + .overrideMediaSourceFactory(source, mediaSourceFactory, mediaDataSourceFactory), + mediaSourceFactory + ); + + mediaItemBuilder.setStreamKeys(streamKeys); + + @Nullable + final MediaItem.Builder overridenMediaItemBuilder = ReactNativeVideoManager.Companion.getInstance().overrideMediaItemBuilder(source, mediaItemBuilder); + + MediaItem mediaItem = overridenMediaItemBuilder != null + ? overridenMediaItemBuilder.build() + : mediaItemBuilder.build(); + + MediaSource mediaSource = mediaSourceFactory + .setDrmSessionManagerProvider(drmProvider) + .setLoadErrorHandlingPolicy( + config.buildLoadErrorHandlingPolicy(source.getMinLoadRetryCount()) + ) + .createMediaSource(mediaItem); + + if (cropStartMs >= 0 && cropEndMs >= 0) { + return new ClippingMediaSource(mediaSource, cropStartMs * 1000, cropEndMs * 1000); + } else if (cropStartMs >= 0) { + return new ClippingMediaSource(mediaSource, cropStartMs * 1000, TIME_END_OF_SOURCE); + } else if (cropEndMs >= 0) { + return new ClippingMediaSource(mediaSource, 0, cropEndMs * 1000); + } + + return mediaSource; + } + + @Nullable + private List buildSubtitleConfigurations() { + if (source.getSideLoadedTextTracks() == null || source.getSideLoadedTextTracks().getTracks().isEmpty()) { + return null; + } + + List subtitleConfigurations = new ArrayList<>(); + int trackIndex = 0; + + for (SideLoadedTextTrack track : source.getSideLoadedTextTracks().getTracks()) { + try { + // Create a more descriptive ID that PlayerView can use + String trackId = "external-subtitle-" + trackIndex; + String label = track.getTitle(); + if (label == null || label.isEmpty()) { + label = "External " + (trackIndex + 1); + if (track.getLanguage() != null && !track.getLanguage().isEmpty()) { + label += " (" + track.getLanguage() + ")"; + } + } + + MediaItem.SubtitleConfiguration.Builder configBuilder = new MediaItem.SubtitleConfiguration.Builder(track.getUri()) + .setId(trackId) + .setMimeType(track.getType()) + .setLabel(label) + .setRoleFlags(C.ROLE_FLAG_SUBTITLE); + + // Set language if available + if (track.getLanguage() != null && !track.getLanguage().isEmpty()) { + configBuilder.setLanguage(track.getLanguage()); + } + + // Set selection flags - make first track default if no specific track is selected + if (trackIndex == 0 && (textTrackType == null || "disabled".equals(textTrackType))) { + configBuilder.setSelectionFlags(C.SELECTION_FLAG_DEFAULT); + } else { + configBuilder.setSelectionFlags(0); + } + + MediaItem.SubtitleConfiguration subtitleConfiguration = configBuilder.build(); + subtitleConfigurations.add(subtitleConfiguration); + + DebugLog.d(TAG, "Created subtitle configuration: " + trackId + " - " + label + " (" + track.getType() + ")"); + trackIndex++; + } catch (Exception e) { + DebugLog.e(TAG, "Error creating SubtitleConfiguration for URI " + track.getUri() + ": " + e.getMessage()); + } + } + + if (!subtitleConfigurations.isEmpty()) { + DebugLog.d(TAG, "Built " + subtitleConfigurations.size() + " external subtitle configurations"); + } + + return subtitleConfigurations.isEmpty() ? null : subtitleConfigurations; + } + + private void releasePlayer() { + if (player != null) { + if(playbackServiceBinder != null) { + playbackServiceBinder.getService().unregisterPlayer(player); + themedReactContext.unbindService(playbackServiceConnection); + } + + updateResumePosition(); + player.release(); + player.removeListener(this); + PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, false); + if (pipListenerUnsubscribe != null) { + pipListenerUnsubscribe.run(); + } + trackSelector = null; + + ReactNativeVideoManager.Companion.getInstance().onInstanceRemoved(instanceId, player); + player = null; + } + + if (adsLoader != null) { + adsLoader.release(); + adsLoader = null; + } + progressHandler.removeMessages(SHOW_PROGRESS); + audioBecomingNoisyReceiver.removeListener(); + pictureInPictureReceiver.removeListener(); + bandwidthMeter.removeEventListener(this); + + if (mainHandler != null && mainRunnable != null) { + mainHandler.removeCallbacks(mainRunnable); + mainRunnable = null; + } + } + + private static class OnAudioFocusChangedListener implements AudioManager.OnAudioFocusChangeListener { + private final ReactExoplayerView view; + private final ThemedReactContext themedReactContext; + + private OnAudioFocusChangedListener(ReactExoplayerView view, ThemedReactContext themedReactContext) { + this.view = view; + this.themedReactContext = themedReactContext; + } + + @Override + public void onAudioFocusChange(int focusChange) { + Activity activity = themedReactContext.getCurrentActivity(); + + switch (focusChange) { + case AudioManager.AUDIOFOCUS_LOSS: + view.hasAudioFocus = false; + view.eventEmitter.onAudioFocusChanged.invoke(false); + // FIXME this pause can cause issue if content doesn't have pause capability (can happen on live channel) + if (activity != null) { + activity.runOnUiThread(view::pausePlayback); + } + view.audioManager.abandonAudioFocus(this); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + view.eventEmitter.onAudioFocusChanged.invoke(false); + break; + case AudioManager.AUDIOFOCUS_GAIN: + view.hasAudioFocus = true; + view.eventEmitter.onAudioFocusChanged.invoke(true); + break; + default: + break; + } + + if (view.player != null && activity != null) { + if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) { + // Lower the volume + if (!view.muted) { + activity.runOnUiThread(() -> + view.player.setVolume(view.audioVolume * 0.8f) + ); + } + } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { + // Raise it back to normal + if (!view.muted) { + activity.runOnUiThread(() -> + view.player.setVolume(view.audioVolume * 1) + ); + } + } + } + } + } + + private boolean requestAudioFocus() { + if (disableFocus || source.getUri() == null || this.hasAudioFocus) { + return true; + } + int result = audioManager.requestAudioFocus(audioFocusChangeListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN); + return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; + } + + private void setPlayWhenReady(boolean playWhenReady) { + if (player == null) { + return; + } + + if (playWhenReady) { + this.hasAudioFocus = requestAudioFocus(); + if (this.hasAudioFocus) { + player.setPlayWhenReady(true); + } + } else { + player.setPlayWhenReady(false); + } + } + + private void resumePlayback() { + if (player != null) { + if (!player.getPlayWhenReady()) { + setPlayWhenReady(true); + } + setKeepScreenOn(preventsDisplaySleepDuringVideoPlayback); + } + } + + private void pausePlayback() { + if (player != null) { + if (player.getPlayWhenReady()) { + setPlayWhenReady(false); + } + } + setKeepScreenOn(false); + } + + private void stopPlayback() { + onStopPlayback(); + releasePlayer(); + } + + private void onStopPlayback() { + audioManager.abandonAudioFocus(audioFocusChangeListener); + } + + private void updateResumePosition() { + resumeWindow = player.getCurrentMediaItemIndex(); + resumePosition = player.isCurrentMediaItemSeekable() ? Math.max(0, player.getCurrentPosition()) + : C.TIME_UNSET; + } + + private void clearResumePosition() { + resumeWindow = C.INDEX_UNSET; + resumePosition = C.TIME_UNSET; + } + + /** + * Returns a new DataSource factory. + * + * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener to the new + * DataSource factory. + * @return A new DataSource factory. + */ + private DataSource.Factory buildDataSourceFactory(boolean useBandwidthMeter) { + return DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, + useBandwidthMeter ? bandwidthMeter : null, source.getHeaders()); + } + + /** + * Returns a new HttpDataSource factory. + * + * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener to the new + * DataSource factory. + * @return A new HttpDataSource factory. + */ + private HttpDataSource.Factory buildHttpDataSourceFactory(boolean useBandwidthMeter) { + return DataSourceUtil.getDefaultHttpDataSourceFactory(this.themedReactContext, useBandwidthMeter ? bandwidthMeter : null, source.getHeaders()); + } + + // AudioBecomingNoisyListener implementation + @Override + public void onAudioBecomingNoisy() { + eventEmitter.onVideoAudioBecomingNoisy.invoke(); + } + + // Player.Listener implementation + @Override + public void onIsLoadingChanged(boolean isLoading) { + // Do nothing. + } + + @Override + public void onEvents(@NonNull Player player, Player.Events events) { + if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED) || events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) { + int playbackState = player.getPlaybackState(); + boolean playWhenReady = player.getPlayWhenReady(); + String text = "onStateChanged: playWhenReady=" + playWhenReady + ", playbackState="; + eventEmitter.onPlaybackRateChange.invoke(playWhenReady && playbackState == ExoPlayer.STATE_READY ? 1.0f : 0.0f); + switch (playbackState) { + case Player.STATE_IDLE: + text += "idle"; + eventEmitter.onVideoIdle.invoke(); + clearProgressMessageHandler(); + if (!player.getPlayWhenReady()) { + setKeepScreenOn(false); + } + break; + case Player.STATE_BUFFERING: + text += "buffering"; + onBuffering(true); + clearProgressMessageHandler(); + setKeepScreenOn(preventsDisplaySleepDuringVideoPlayback); + break; + case Player.STATE_READY: + text += "ready"; + eventEmitter.onReadyForDisplay.invoke(); + onBuffering(false); + clearProgressMessageHandler(); // ensure there is no other message + startProgressHandler(); + videoLoaded(); + if (selectTrackWhenReady && isUsingContentResolution) { + selectTrackWhenReady = false; + setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); + } + // Setting the visibility for the player controls + if (exoPlayerView != null) { + exoPlayerView.showController(); + } + setKeepScreenOn(preventsDisplaySleepDuringVideoPlayback); + break; + case Player.STATE_ENDED: + text += "ended"; + updateProgress(); + eventEmitter.onVideoEnd.invoke(); + onStopPlayback(); + setKeepScreenOn(false); + break; + default: + text += "unknown"; + break; + } + DebugLog.d(TAG, text); + } + } + + private void startProgressHandler() { + progressHandler.sendEmptyMessage(SHOW_PROGRESS); + } + + /** + * The progress message handler will duplicate recursions of the onProgressMessage handler + * on change of player state from any state to STATE_READY with playWhenReady is true (when + * the video is not paused). This clears all existing messages. + */ + private void clearProgressMessageHandler() { + progressHandler.removeMessages(SHOW_PROGRESS); + } + + private void videoLoaded() { + if (!player.isPlayingAd() && loadVideoStarted) { + loadVideoStarted = false; + if (audioTrackType != null) { + setSelectedAudioTrack(audioTrackType, audioTrackValue); + } + if (videoTrackType != null) { + setSelectedVideoTrack(videoTrackType, videoTrackValue); + } + if (textTrackType != null) { + setSelectedTextTrack(textTrackType, textTrackValue); + } + Format videoFormat = player.getVideoFormat(); + boolean isRotatedContent = videoFormat != null && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270); + int width = videoFormat != null ? (isRotatedContent ? videoFormat.height : videoFormat.width) : 0; + int height = videoFormat != null ? (isRotatedContent ? videoFormat.width : videoFormat.height) : 0; + String trackId = videoFormat != null ? videoFormat.id : null; + + // Properties that must be accessed on the main thread + long duration = player.getDuration(); + long currentPosition = player.getCurrentPosition(); + ArrayList audioTracks = getAudioTrackInfo(); + ArrayList textTracks = getTextTrackInfo(); + + if (source.getContentStartTime() != -1) { + ExecutorService es = Executors.newSingleThreadExecutor(); + es.execute(() -> { + // To prevent ANRs caused by getVideoTrackInfo we run this on a different thread and notify the player only when we're done + ArrayList videoTracks = getVideoTrackInfoFromManifest(); + if (videoTracks != null) { + isUsingContentResolution = true; + } + eventEmitter.onVideoLoad.invoke(duration, currentPosition, width, height, + audioTracks, textTracks, videoTracks, trackId ); + + updateSubtitleButtonVisibility(); + }); + return; + } + + ArrayList videoTracks = getVideoTrackInfo(); + + eventEmitter.onVideoLoad.invoke(duration, currentPosition, width, height, + audioTracks, textTracks, videoTracks, trackId); + + updateSubtitleButtonVisibility(); + refreshControlsStyles(); + } + } + + private static boolean isTrackSelected(TrackSelection selection, TrackGroup group, + int trackIndex){ + return selection != null && selection.getTrackGroup() == group + && selection.indexOf( trackIndex ) != C.INDEX_UNSET; + } + + private ArrayList getAudioTrackInfo() { + ArrayList audioTracks = new ArrayList<>(); + if (trackSelector == null) { + return audioTracks; + } + + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); + int index = getTrackRendererIndex(C.TRACK_TYPE_AUDIO); + if (info == null || index == C.INDEX_UNSET) { + return audioTracks; + } + TrackGroupArray groups = info.getTrackGroups(index); + TrackSelectionArray selectionArray = player.getCurrentTrackSelections(); + TrackSelection selection = selectionArray.get(C.TRACK_TYPE_AUDIO); + + + for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) { + TrackGroup group = groups.get(groupIndex); + Format format = group.getFormat(0); + + // Check if this specific group is the currently selected one + boolean isSelected = false; + if (selection != null && selection.getTrackGroup() == group) { + isSelected = true; + } + + Track audioTrack = exoplayerTrackToGenericTrack(format, groupIndex, selection, group); + audioTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); + audioTrack.setSelected(isSelected); + audioTracks.add(audioTrack); + } + + return audioTracks; + } + + private VideoTrack exoplayerVideoTrackToGenericVideoTrack(Format format, int trackIndex) { + VideoTrack videoTrack = new VideoTrack(); + videoTrack.setWidth(format.width == Format.NO_VALUE ? 0 : format.width); + videoTrack.setHeight(format.height == Format.NO_VALUE ? 0 : format.height); + videoTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); + videoTrack.setRotation(format.rotationDegrees); + if (format.codecs != null) videoTrack.setCodecs(format.codecs); + videoTrack.setTrackId(format.id == null ? String.valueOf(trackIndex) : format.id); + videoTrack.setIndex(trackIndex); + return videoTrack; + } + + private ArrayList getVideoTrackInfo() { + ArrayList videoTracks = new ArrayList<>(); + if (trackSelector == null) { + // Likely player is unmounting so no video tracks are available anymore + return videoTracks; + } + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); + int index = getTrackRendererIndex(C.TRACK_TYPE_VIDEO); + if (info == null || index == C.INDEX_UNSET) { + return videoTracks; + } + + TrackGroupArray groups = info.getTrackGroups(index); + for (int i = 0; i < groups.length; ++i) { + TrackGroup group = groups.get(i); + + for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { + Format format = group.getFormat(trackIndex); + if (isFormatSupported(format)) { + VideoTrack videoTrack = exoplayerVideoTrackToGenericVideoTrack(format, trackIndex); + videoTracks.add(videoTrack); + } + } + } + return videoTracks; + } + + private ArrayList getVideoTrackInfoFromManifest() { + return this.getVideoTrackInfoFromManifest(0); + } + + // We need retry count to in case where minefest request fails from poor network conditions + @WorkerThread + private ArrayList getVideoTrackInfoFromManifest(int retryCount) { + ExecutorService es = Executors.newSingleThreadExecutor(); + final DataSource dataSource = this.mediaDataSourceFactory.createDataSource(); + final Uri sourceUri = source.getUri(); + final long startTime = source.getContentStartTime() * 1000 - 100; // s -> ms with 100ms offset + + Future> result = es.submit(new Callable() { + final DataSource ds = dataSource; + final Uri uri = sourceUri; + final long startTimeUs = startTime * 1000; // ms -> us + + public ArrayList call() { + ArrayList videoTracks = new ArrayList<>(); + try { + DashManifest manifest = DashUtil.loadManifest(this.ds, this.uri); + int periodCount = manifest.getPeriodCount(); + for (int i = 0; i < periodCount; i++) { + Period period = manifest.getPeriod(i); + for (int adaptationIndex = 0; adaptationIndex < period.adaptationSets.size(); adaptationIndex++) { + AdaptationSet adaptation = period.adaptationSets.get(adaptationIndex); + if (adaptation.type != C.TRACK_TYPE_VIDEO) { + continue; + } + boolean hasFoundContentPeriod = false; + for (int representationIndex = 0; representationIndex < adaptation.representations.size(); representationIndex++) { + Representation representation = adaptation.representations.get(representationIndex); + Format format = representation.format; + if (isFormatSupported(format)) { + if (representation.presentationTimeOffsetUs <= startTimeUs) { + break; + } + hasFoundContentPeriod = true; + VideoTrack videoTrack = exoplayerVideoTrackToGenericVideoTrack(format, representationIndex); + videoTracks.add(videoTrack); + } + } + if (hasFoundContentPeriod) { + return videoTracks; + } + } + } + } catch (Exception e) { + DebugLog.w(TAG, "error in getVideoTrackInfoFromManifest:" + e.getMessage()); + } + return null; + } + }); + + try { + ArrayList results = result.get(3000, TimeUnit.MILLISECONDS); + if (results == null && retryCount < 1) { + return this.getVideoTrackInfoFromManifest(++retryCount); + } + es.shutdown(); + return results; + } catch (Exception e) { + DebugLog.w(TAG, "error in getVideoTrackInfoFromManifest handling request:" + e.getMessage()); + } + + return null; + } + + private Track exoplayerTrackToGenericTrack(Format format, int trackIndex, TrackSelection selection, TrackGroup group) { + Track track = new Track(); + track.setIndex(trackIndex); + if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType); + if (format.language != null) track.setLanguage(format.language); + if (format.label != null) track.setTitle(format.label); + track.setSelected(isTrackSelected(selection, group, trackIndex)); + return track; + } + + private ArrayList getTextTrackInfo() { + ArrayList textTracks = new ArrayList<>(); + if (trackSelector == null) { + return textTracks; + } + + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); + int index = getTrackRendererIndex(C.TRACK_TYPE_TEXT); + if (info == null || index == C.INDEX_UNSET) { + return textTracks; + } + + TrackSelectionArray selectionArray = player.getCurrentTrackSelections(); + TrackSelection selection = selectionArray.get(C.TRACK_TYPE_TEXT); + TrackGroupArray groups = info.getTrackGroups(index); + + for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) { + TrackGroup group = groups.get(groupIndex); + for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { + Format format = group.getFormat(trackIndex); + Track textTrack = exoplayerTrackToGenericTrack(format, trackIndex, selection, group); + + boolean isExternal = format.id != null && format.id.startsWith("external-subtitle-"); + boolean isSelected = isTrackSelected(selection, group, trackIndex); + + textTrack.setIndex(textTracks.size()); + + if (textTrack.getTitle() == null || textTrack.getTitle().isEmpty()) { + if (isExternal) { + textTrack.setTitle("External " + (trackIndex + 1)); + } else { + textTrack.setTitle("Track " + (textTracks.size() + 1)); + } + } + + textTracks.add(textTrack); + } + } + return textTracks; + } + + private ArrayList getBasicAudioTrackInfo() { + ArrayList tracks = new ArrayList<>(); + if (trackSelector == null) { + return tracks; + } + + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); + int index = getTrackRendererIndex(C.TRACK_TYPE_AUDIO); + if (info == null || index == C.INDEX_UNSET) { + return tracks; + } + + TrackGroupArray groups = info.getTrackGroups(index); + + for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) { + TrackGroup group = groups.get(groupIndex); + Format format = group.getFormat(0); + + // Create track without trying to determine selection status + Track track = new Track(); + track.setIndex(groupIndex); + track.setLanguage(format.language != null ? format.language : "unknown"); + track.setTitle(format.label != null ? format.label : "Track " + (groupIndex + 1)); + track.setSelected(false); // Don't report selection status - let PlayerView handle it + if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType); + track.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); + + tracks.add(track); + } + + DebugLog.d(TAG, "getBasicAudioTrackInfo: returning " + tracks.size() + " audio tracks (no selection status)"); + return tracks; + } + + private ArrayList getBasicTextTrackInfo() { + ArrayList textTracks = new ArrayList<>(); + if (trackSelector == null) { + return textTracks; + } + + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); + int index = getTrackRendererIndex(C.TRACK_TYPE_TEXT); + if (info == null || index == C.INDEX_UNSET) { + return textTracks; + } + + TrackGroupArray groups = info.getTrackGroups(index); + + for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) { + TrackGroup group = groups.get(groupIndex); + for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { + Format format = group.getFormat(trackIndex); + + Track textTrack = new Track(); + textTrack.setIndex(textTracks.size()); + if (format.sampleMimeType != null) textTrack.setMimeType(format.sampleMimeType); + if (format.language != null) textTrack.setLanguage(format.language); + + boolean isExternal = format.id != null && format.id.startsWith("external-subtitle-"); + + if (format.label != null && !format.label.isEmpty()) { + textTrack.setTitle(format.label); + } else if (isExternal) { + textTrack.setTitle("External " + (trackIndex + 1)); + } else { + textTrack.setTitle("Track " + (textTracks.size() + 1)); + } + + textTrack.setSelected(false); // Don't report selection status - let PlayerView handle it + textTracks.add(textTrack); + } + } + return textTracks; + } + + private void onBuffering(boolean buffering) { + if (isBuffering == buffering) { + return; + } + + if (isPaused && isSeeking && !buffering) { + eventEmitter.onVideoSeek.invoke(player.getCurrentPosition(), seekPosition); + isSeeking = false; + } + + isBuffering = buffering; + eventEmitter.onVideoBuffer.invoke(buffering); + } + + @Override + public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, @NonNull Player.PositionInfo newPosition, @Player.DiscontinuityReason int reason) { + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + isSeeking = true; + seekPosition = newPosition.positionMs; + if (isUsingContentResolution) { + // We need to update the selected track to make sure that it still matches user selection if track list has changed in this period + setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); + } + } + + if (playerNeedsSource) { + // This will only occur if the user has performed a seek whilst in the error state. Update the + // resume position so that if the user then retries, playback will resume from the position to + // which they seeked. + updateResumePosition(); + } + if (isUsingContentResolution) { + // Discontinuity events might have a different track list so we update the selected track + setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); + selectTrackWhenReady = true; + } + // When repeat is turned on, reaching the end of the video will not cause a state change + // so we need to explicitly detect it. + if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION + && player.getRepeatMode() == Player.REPEAT_MODE_ONE) { + updateProgress(); + eventEmitter.onVideoEnd.invoke(); + } + } + + @Override + public void onTimelineChanged(@NonNull Timeline timeline, int reason) { + // Do nothing. + } + + @Override + public void onTracksChanged(@NonNull Tracks tracks) { + DebugLog.d(TAG, "onTracksChanged called - updating track information, controls=" + controls); + + if (controls) { + ArrayList textTracks = getBasicTextTrackInfo(); + ArrayList audioTracks = getBasicAudioTrackInfo(); + ArrayList videoTracks = getVideoTrackInfo(); + + eventEmitter.onTextTracks.invoke(textTracks); + eventEmitter.onAudioTracks.invoke(audioTracks); + eventEmitter.onVideoTracks.invoke(videoTracks); + } else { + ArrayList textTracks = getTextTrackInfo(); + ArrayList audioTracks = getAudioTrackInfo(); + ArrayList videoTracks = getVideoTrackInfo(); + + eventEmitter.onTextTracks.invoke(textTracks); + eventEmitter.onAudioTracks.invoke(audioTracks); + eventEmitter.onVideoTracks.invoke(videoTracks); + int selectedAudioTracks = 0; + for (Track track : audioTracks) { + if (track.isSelected()) { + selectedAudioTracks++; + } + } + } + + updateSubtitleButtonVisibility(); + } + + + private boolean hasBuiltInTextTracks() { + if (player == null || trackSelector == null) return false; + + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); + if (info == null) return false; + + int textRendererIndex = getTrackRendererIndex(C.TRACK_TYPE_TEXT); + if (textRendererIndex == C.INDEX_UNSET) return false; + + TrackGroupArray groups = info.getTrackGroups(textRendererIndex); + + // Check if any groups have tracks that are NOT external subtitles + for (int i = 0; i < groups.length; i++) { + TrackGroup group = groups.get(i); + for (int j = 0; j < group.length; j++) { + Format format = group.getFormat(j); + // If track ID doesn't start with "external-subtitle-", it's built-in + if (format.id == null || !format.id.startsWith("external-subtitle-")) { + return true; + } + } + } + + return false; + } + + private void updateSubtitleButtonVisibility() { + if (exoPlayerView == null) return; + + boolean hasTextTracks = (source.getSideLoadedTextTracks() != null && + !source.getSideLoadedTextTracks().getTracks().isEmpty()) || + hasBuiltInTextTracks(); + + exoPlayerView.setShowSubtitleButton(hasTextTracks); + } + + @Override + public void onPlaybackParametersChanged(PlaybackParameters params) { + eventEmitter.onPlaybackRateChange.invoke(params.speed); + } + + @Override + public void onVolumeChanged(float volume) { + eventEmitter.onVolumeChange.invoke(volume); + } + + @Override + public void onIsPlayingChanged(boolean isPlaying) { + if (isPlaying && isSeeking) { + eventEmitter.onVideoSeek.invoke(player.getCurrentPosition(), seekPosition); + } + PictureInPictureUtil.applyPlayingStatus(themedReactContext, pictureInPictureParamsBuilder, pictureInPictureReceiver, !isPlaying); + eventEmitter.onVideoPlaybackStateChanged.invoke(isPlaying, isSeeking); + + if (isPlaying) { + isSeeking = false; + } + } + + @Override + public void onPlayerError(@NonNull PlaybackException e) { + String errorString = "ExoPlaybackException: " + PlaybackException.getErrorCodeName(e.errorCode); + String errorCode = "2" + e.errorCode; + switch(e.errorCode) { + case PlaybackException.ERROR_CODE_DRM_DEVICE_REVOKED: + case PlaybackException.ERROR_CODE_DRM_LICENSE_ACQUISITION_FAILED: + case PlaybackException.ERROR_CODE_DRM_PROVISIONING_FAILED: + case PlaybackException.ERROR_CODE_DRM_SYSTEM_ERROR: + case PlaybackException.ERROR_CODE_DRM_UNSPECIFIED: + if (!hasDrmFailed) { + // When DRM fails to reach the app level certificate server it will fail with a source error so we assume that it is DRM related and try one more time + hasDrmFailed = true; + playerNeedsSource = true; + updateResumePosition(); + initializePlayer(); + setPlayWhenReady(true); + return; + } + break; + default: + break; + } + eventEmitter.onVideoError.invoke(errorString, e, errorCode); + playerNeedsSource = true; + if (isBehindLiveWindow(e)) { + clearResumePosition(); + if (player != null) { + player.seekToDefaultPosition(); + player.prepare(); + } + } else { + updateResumePosition(); + } + } + + private static boolean isBehindLiveWindow(PlaybackException e) { + return e.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW; + } + + public int getTrackRendererIndex(int trackType) { + if (player != null) { + int rendererCount = player.getRendererCount(); + for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) { + if (player.getRendererType(rendererIndex) == trackType) { + return rendererIndex; + } + } + } + return C.INDEX_UNSET; + } + + @Override + public void onMetadata(@NonNull Metadata metadata) { + ArrayList metadataArray = new ArrayList<>(); + for (int i = 0; i < metadata.length(); i++) { + Metadata.Entry entry = metadata.get(i); + + if (entry instanceof Id3Frame) { + Id3Frame frame = (Id3Frame) metadata.get(i); + + String value = ""; + + if (frame instanceof TextInformationFrame) { + TextInformationFrame txxxFrame = (TextInformationFrame) frame; + value = txxxFrame.value; + } + TimedMetadata timedMetadata = new TimedMetadata(frame.id, value); + metadataArray.add(timedMetadata); + } else if (entry instanceof EventMessage) { + EventMessage eventMessage = (EventMessage) entry; + TimedMetadata timedMetadata = new TimedMetadata(eventMessage.schemeIdUri, eventMessage.value); + metadataArray.add(timedMetadata); + } else { + DebugLog.d(TAG, "unhandled metadata " + entry); + } + } + eventEmitter.onTimedMetadata.invoke(metadataArray); + } + + public void onCues(CueGroup cueGroup) { + if (!cueGroup.cues.isEmpty() && cueGroup.cues.get(0).text != null) { + String subtitleText = cueGroup.cues.get(0).text.toString(); + eventEmitter.onTextTrackDataChanged.invoke(subtitleText); + } + } + + public void setSrc(Source source) { + if (source.getUri() != null) { + clearResumePosition(); + boolean isSourceEqual = source.isEquals(this.source); + hasDrmFailed = false; + this.source = source; + final DataSource.Factory tmpMediaDataSourceFactory = + DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, bandwidthMeter, + source.getHeaders()); + + @Nullable + final DataSource.Factory overriddenMediaDataSourceFactory = ReactNativeVideoManager.Companion.getInstance().overrideMediaDataSourceFactory(source, tmpMediaDataSourceFactory); + + this.mediaDataSourceFactory = Objects.requireNonNullElse(overriddenMediaDataSourceFactory, tmpMediaDataSourceFactory); + + if (source.getCmcdProps() != null) { + CMCDConfig cmcdConfig = new CMCDConfig(source.getCmcdProps()); + CmcdConfiguration.Factory factory = cmcdConfig.toCmcdConfigurationFactory(); + this.setCmcdConfigurationFactory(factory); + } else { + this.setCmcdConfigurationFactory(null); + } + + if (!isSourceEqual) { + playerNeedsSource = true; + initializePlayer(); + } + } else { + clearSrc(); + } + } + public void clearSrc() { + if (source.getUri() != null) { + if (player != null) { + player.stop(); + player.clearMediaItems(); + } + } + + this.source = new Source(); + this.mediaDataSourceFactory = null; + clearResumePosition(); + } + + public void setProgressUpdateInterval(final float progressUpdateInterval) { + mProgressUpdateInterval = progressUpdateInterval; + } + + public void setReportBandwidth(boolean reportBandwidth) { + mReportBandwidth = reportBandwidth; + } + + public void setResizeModeModifier(@ResizeMode.Mode int resizeMode) { + if (exoPlayerView != null) { + exoPlayerView.setResizeMode(resizeMode); + } + } + + private void applyModifiers() { + setRepeatModifier(repeat); + setMutedModifier(muted); + } + + public void setRepeatModifier(boolean repeat) { + if (player != null) { + if (repeat) { + player.setRepeatMode(Player.REPEAT_MODE_ONE); + } else { + player.setRepeatMode(Player.REPEAT_MODE_OFF); + } + } + this.repeat = repeat; + } + + public void setPreventsDisplaySleepDuringVideoPlayback(boolean preventsDisplaySleepDuringVideoPlayback) { + this.preventsDisplaySleepDuringVideoPlayback = preventsDisplaySleepDuringVideoPlayback; + } + + public void disableTrack(int rendererIndex) { + if (trackSelector == null) return; + + DefaultTrackSelector.Parameters disableParameters = trackSelector.getParameters() + .buildUpon() + .setRendererDisabled(rendererIndex, true) + .build(); + trackSelector.setParameters(disableParameters); + } + + private void selectTextTrackInternal(String type, String value) { + if (player == null || trackSelector == null) return; + + DebugLog.d(TAG, "selectTextTrackInternal: type=" + type + ", value=" + value); + + DefaultTrackSelector.Parameters.Builder parametersBuilder = trackSelector.getParameters().buildUpon(); + + if ("disabled".equals(type) || value == null) { + parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true); + } else { + parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false); + + parametersBuilder.clearOverridesOfType(C.TRACK_TYPE_TEXT); + + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); + if (info != null) { + int textRendererIndex = getTrackRendererIndex(C.TRACK_TYPE_TEXT); + if (textRendererIndex != C.INDEX_UNSET) { + TrackGroupArray groups = info.getTrackGroups(textRendererIndex); + boolean trackFound = false; + + for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { + TrackGroup group = groups.get(groupIndex); + for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { + Format format = group.getFormat(trackIndex); + + boolean isMatch = false; + if ("language".equals(type) && format.language != null && format.language.equals(value)) { + isMatch = true; + } else if ("title".equals(type) && format.label != null && format.label.equals(value)) { + isMatch = true; + } else if ("index".equals(type)) { + int targetIndex = ReactBridgeUtils.safeParseInt(value, -1); + if (targetIndex == trackIndex) { + isMatch = true; + } + } + + if (isMatch) { + TrackSelectionOverride override = new TrackSelectionOverride(group, + java.util.Arrays.asList(trackIndex)); + parametersBuilder.addOverride(override); + trackFound = true; + break; + } + } + if (trackFound) break; + } + + if (!trackFound) { + DebugLog.w(TAG, "Text track not found for type=" + type + ", value=" + value + + ". Keeping current selection."); + } + } + } + } + + try { + trackSelector.setParameters(parametersBuilder.build()); + + // Give PlayerView time to update its controls + mainHandler.postDelayed(() -> { + if (exoPlayerView != null) { + updateSubtitleButtonVisibility(); + } + }, 100); + } catch (Exception e) { + DebugLog.e(TAG, "Error setting text track parameters: " + e.getMessage()); + } + } + + public void setSelectedTrack(int trackType, String type, String value) { + if (player == null || trackSelector == null) return; + + if (controls) { + return; + } + + int rendererIndex = getTrackRendererIndex(trackType); + if (rendererIndex == C.INDEX_UNSET) { + return; + } + + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); + if (info == null) { + return; + } + + TrackGroupArray groups = info.getTrackGroups(rendererIndex); + int groupIndex = C.INDEX_UNSET; + List tracks = new ArrayList<>(); + tracks.add(0); + + if (TextUtils.isEmpty(type)) { + type = "default"; + } + + if ("disabled".equals(type)) { + disableTrack(rendererIndex); + return; + } else if ("language".equals(type)) { + for (int i = 0; i < groups.length; ++i) { + Format format = groups.get(i).getFormat(0); + if (format.language != null && format.language.equals(value)) { + groupIndex = i; + break; + } + } + } else if ("title".equals(type)) { + for (int i = 0; i < groups.length; ++i) { + Format format = groups.get(i).getFormat(0); + if (format.label != null && format.label.equals(value)) { + groupIndex = i; + break; + } + } + } else if ("index".equals(type)) { + int iValue = ReactBridgeUtils.safeParseInt(value, -1); + if (iValue != -1) { + if (trackType == C.TRACK_TYPE_VIDEO && groups.length == 1) { + groupIndex = 0; + if (iValue < groups.get(groupIndex).length) { + tracks.set(0, iValue); + } + } else if (iValue < groups.length) { + groupIndex = iValue; + } + } + } else if ("resolution".equals(type)) { + int height = ReactBridgeUtils.safeParseInt(value, -1); + if (height != -1) { + for (int i = 0; i < groups.length; ++i) { // Search for the exact height + TrackGroup group = groups.get(i); + Format closestFormat = null; + int closestTrackIndex = -1; + boolean usingExactMatch = false; + for (int j = 0; j < group.length; j++) { + Format format = group.getFormat(j); + if (format.height == height) { + groupIndex = i; + tracks.set(0, j); + closestFormat = null; + closestTrackIndex = -1; + usingExactMatch = true; + break; + } else if (isUsingContentResolution) { + // When using content resolution rather than ads, we need to try and find the closest match if there is no exact match + if (closestFormat != null) { + if ((format.bitrate > closestFormat.bitrate || format.height > closestFormat.height) && format.height < height) { + // Higher quality match + closestFormat = format; + closestTrackIndex = j; + } + } else if (format.height < height) { + closestFormat = format; + closestTrackIndex = j; + } + } + } + // This is a fallback if the new period contains only higher resolutions than the user has selected + if (closestFormat == null && isUsingContentResolution && !usingExactMatch) { + // No close match found - so we pick the lowest quality + int minHeight = Integer.MAX_VALUE; + for (int j = 0; j < group.length; j++) { + Format format = group.getFormat(j); + if (format.height < minHeight) { + minHeight = format.height; + groupIndex = i; + tracks.set(0, j); + } + } + } + // Selecting the closest match found + if (closestFormat != null && closestTrackIndex != -1) { + // We found the closest match instead of an exact one + groupIndex = i; + tracks.set(0, closestTrackIndex); + } + } + } + } else if (trackType == C.TRACK_TYPE_TEXT && Util.SDK_INT > 18) { // Text default + // Use system settings if possible + CaptioningManager captioningManager + = (CaptioningManager)themedReactContext.getSystemService(Context.CAPTIONING_SERVICE); + if (captioningManager != null && captioningManager.isEnabled()) { + groupIndex = getGroupIndexForDefaultLocale(groups); + } + } else if (trackType == C.TRACK_TYPE_AUDIO) { // Audio default + groupIndex = getGroupIndexForDefaultLocale(groups); + } + + if (groupIndex == C.INDEX_UNSET && trackType == C.TRACK_TYPE_VIDEO && groups.length != 0) { // Video auto + // Add all tracks as valid options for ABR to choose from + TrackGroup group = groups.get(0); + ArrayList allTracks = new ArrayList<>(group.length); + groupIndex = 0; + for (int j = 0; j < group.length; j++) { + allTracks.add(j); + } + + // Valiate list of all tracks and add only supported formats + int supportedFormatLength = 0; + for (int g = 0; g < allTracks.size(); g++) { + Format format = group.getFormat(g); + if (isFormatSupported(format)) { + supportedFormatLength++; + } + } + if (allTracks.size() == 1) { + // With only one tracks we can't remove any tracks so attempt to play it anyway + tracks = allTracks; + } else { + tracks = new ArrayList<>(supportedFormatLength + 1); + for (int k = 0; k < allTracks.size(); k++) { + Format format = group.getFormat(k); + if (isFormatSupported(format)) { + tracks.add(allTracks.get(k)); + } + } + } + } + + if (groupIndex == C.INDEX_UNSET) { + disableTrack(rendererIndex); + return; + } + + try { + TrackSelectionOverride selectionOverride = new TrackSelectionOverride(groups.get(groupIndex), tracks); + + DefaultTrackSelector.Parameters.Builder selectionParameters = trackSelector.getParameters() + .buildUpon() + .setExceedAudioConstraintsIfNecessary(true) + .setExceedRendererCapabilitiesIfNecessary(true) + .setExceedVideoConstraintsIfNecessary(true) + .setRendererDisabled(rendererIndex, false); + + // Clear existing overrides for this track type to avoid conflicts + // But be careful with audio tracks - don't clear unless explicitly selecting a different track + if (trackType != C.TRACK_TYPE_AUDIO || !type.equals("default")) { + selectionParameters.clearOverridesOfType(selectionOverride.getType()); + } + + if (trackType == C.TRACK_TYPE_VIDEO && isUsingVideoABR()) { + selectionParameters.setMaxVideoBitrate(maxBitRate == 0 ? Integer.MAX_VALUE : maxBitRate); + } else { + selectionParameters.addOverride(selectionOverride); + } + + // For audio tracks, ensure audio renderer stays enabled + if (trackType == C.TRACK_TYPE_AUDIO) { + selectionParameters.setForceHighestSupportedBitrate(false); + selectionParameters.setForceLowestBitrate(false); + DebugLog.d(TAG, "Audio track selection: group=" + groupIndex + ", tracks=" + tracks + + ", override=" + selectionOverride); + } + + trackSelector.setParameters(selectionParameters.build()); + DebugLog.d(TAG, "Applied track selection for type: " + trackType + ", group: " + groupIndex); + } catch (Exception e) { + DebugLog.e(TAG, "Error applying track selection: " + e.getMessage()); + e.printStackTrace(); + } + } + + private boolean isFormatSupported(Format format) { + int width = format.width == Format.NO_VALUE ? 0 : format.width; + int height = format.height == Format.NO_VALUE ? 0 : format.height; + float frameRate = format.frameRate == Format.NO_VALUE ? 0 : format.frameRate; + String mimeType = format.sampleMimeType; + if (mimeType == null) { + return true; + } + boolean isSupported; + try { + MediaCodecInfo codecInfo = MediaCodecUtil.getDecoderInfo(mimeType, false, false); + isSupported = codecInfo.isVideoSizeAndRateSupportedV21(width, height, frameRate); + } catch (Exception e) { + // Failed to get decoder info - assume it is supported + isSupported = true; + } + return isSupported; + } + + private int getGroupIndexForDefaultLocale(TrackGroupArray groups) { + if (groups.length == 0){ + return C.INDEX_UNSET; + } + + int groupIndex = 0; // default if no match + String locale2 = Locale.getDefault().getLanguage(); // 2 letter code + String locale3 = Locale.getDefault().getISO3Language(); // 3 letter code + for (int i = 0; i < groups.length; ++i) { + Format format = groups.get(i).getFormat(0); + String language = format.language; + if (language != null && (language.equals(locale2) || language.equals(locale3))) { + groupIndex = i; + break; + } + } + return groupIndex; + } + + public void setSelectedVideoTrack(String type, String value) { + videoTrackType = type; + videoTrackValue = value; + if (!loadVideoStarted) setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); + } + + public void setSelectedAudioTrack(String type, String value) { + audioTrackType = type; + audioTrackValue = value; + + if (!controls && player != null && trackSelector != null) { + setSelectedTrack(C.TRACK_TYPE_AUDIO, audioTrackType, audioTrackValue); + } + } + + public void setSelectedTextTrack(String type, String value) { + textTrackType = type; + textTrackValue = value; + + selectTextTrackInternal(type, value); + } + + public void setPausedModifier(boolean paused) { + isPaused = paused; + if (player != null) { + if (!paused) { + resumePlayback(); + } else { + pausePlayback(); + } + } + } + + public void setEnterPictureInPictureOnLeave(boolean enterPictureInPictureOnLeave) { + this.enterPictureInPictureOnLeave = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && enterPictureInPictureOnLeave; + if (player != null) { + PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, this.enterPictureInPictureOnLeave); + } + } + + protected void setIsInPictureInPicture(boolean isInPictureInPicture) { + eventEmitter.onPictureInPictureStatusChanged.invoke(isInPictureInPicture); + + if (fullScreenPlayerView != null && fullScreenPlayerView.isShowing()) { + if (isInPictureInPicture) fullScreenPlayerView.hideWithoutPlayer(); + return; + } + + Activity currentActivity = themedReactContext.getCurrentActivity(); + if (currentActivity == null) return; + + View decorView = currentActivity.getWindow().getDecorView(); + ViewGroup rootView = decorView.findViewById(android.R.id.content); + + LayoutParams layoutParams = new LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT); + + if (isInPictureInPicture) { + ViewGroup parent = (ViewGroup)exoPlayerView.getParent(); + if (parent != null) { + parent.removeView(exoPlayerView); + } + for (int i = 0; i < rootView.getChildCount(); i++) { + if (rootView.getChildAt(i) != exoPlayerView) { + rootViewChildrenOriginalVisibility.add(rootView.getChildAt(i).getVisibility()); + rootView.getChildAt(i).setVisibility(View.GONE); + } + } + rootView.addView(exoPlayerView, layoutParams); + } else { + rootView.removeView(exoPlayerView); + if (!rootViewChildrenOriginalVisibility.isEmpty()) { + for (int i = 0; i < rootView.getChildCount(); i++) { + rootView.getChildAt(i).setVisibility(rootViewChildrenOriginalVisibility.get(i)); + } + addView(exoPlayerView, 0, layoutParams); + } + } + } + + public void enterPictureInPictureMode() { + PictureInPictureParams _pipParams = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ArrayList actions = PictureInPictureUtil.getPictureInPictureActions(themedReactContext, isPaused, pictureInPictureReceiver); + pictureInPictureParamsBuilder.setActions(actions); + if (player.getPlaybackState() == Player.STATE_READY) { + pictureInPictureParamsBuilder.setAspectRatio(PictureInPictureUtil.calcPictureInPictureAspectRatio(player)); + } + _pipParams = pictureInPictureParamsBuilder.build(); + } + PictureInPictureUtil.enterPictureInPictureMode(themedReactContext, _pipParams); + } + + public void exitPictureInPictureMode() { + Activity currentActivity = themedReactContext.getCurrentActivity(); + if (currentActivity == null) return; + + View decorView = currentActivity.getWindow().getDecorView(); + ViewGroup rootView = decorView.findViewById(android.R.id.content); + + if (!rootViewChildrenOriginalVisibility.isEmpty()) { + if (exoPlayerView.getParent().equals(rootView)) rootView.removeView(exoPlayerView); + for (int i = 0; i < rootView.getChildCount(); i++) { + rootView.getChildAt(i).setVisibility(rootViewChildrenOriginalVisibility.get(i)); + } + rootViewChildrenOriginalVisibility.clear(); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && currentActivity.isInPictureInPictureMode()) { + currentActivity.moveTaskToBack(false); + } + } + + public void setMutedModifier(boolean muted) { + this.muted = muted; + if (player != null) { + player.setVolume(muted ? 0.f : audioVolume); + } + } + + private void changeAudioOutput(AudioOutput output) { + if (player != null) { + int streamType = output.getStreamType(); + int usage = Util.getAudioUsageForStreamType(streamType); + int contentType = Util.getAudioContentTypeForStreamType(streamType); + AudioAttributes audioAttributes = new AudioAttributes.Builder().setUsage(usage) + .setContentType(contentType) + .build(); + player.setAudioAttributes(audioAttributes, false); + AudioManager audioManager = (AudioManager) themedReactContext.getSystemService(Context.AUDIO_SERVICE); + boolean isSpeakerOutput = output == AudioOutput.SPEAKER; + audioManager.setMode( + isSpeakerOutput ? AudioManager.MODE_NORMAL + : AudioManager.MODE_IN_COMMUNICATION); + audioManager.setSpeakerphoneOn(isSpeakerOutput); + } + } + + public void setAudioOutput(AudioOutput output) { + if (audioOutput != output) { + this.audioOutput = output; + changeAudioOutput(output); + } + } + + public void setVolumeModifier(float volume) { + audioVolume = volume; + if (player != null) { + player.setVolume(audioVolume); + } + } + + public void seekTo(long positionMs) { + if (player != null) { + player.seekTo(positionMs); + } + } + + public void setRateModifier(float newRate) { + if (newRate <= 0) { + DebugLog.w(TAG, "cannot set rate <= 0"); + return; + } + + rate = newRate; + + if (player != null) { + PlaybackParameters params = new PlaybackParameters(rate, 1f); + player.setPlaybackParameters(params); + } + } + + public void setMaxBitRateModifier(int newMaxBitRate) { + maxBitRate = newMaxBitRate; + if (player != null && isUsingVideoABR()) { + // do not apply yet if not auto + trackSelector.setParameters(trackSelector.buildUponParameters() + .setMaxVideoBitrate(maxBitRate == 0 ? Integer.MAX_VALUE : maxBitRate)); + } + } + + public void setPlayInBackground(boolean playInBackground) { + this.playInBackground = playInBackground; + } + + public void setDisableFocus(boolean disableFocus) { + this.disableFocus = disableFocus; + } + + public void setFocusable(boolean focusable) { + this.focusable = focusable; + exoPlayerView.setFocusable(this.focusable); + } + + public void setShowNotificationControls(boolean showNotificationControls) { + this.showNotificationControls = showNotificationControls; + + if (playbackServiceConnection == null && showNotificationControls) { + setupPlaybackService(); + } else if(!showNotificationControls && playbackServiceConnection != null) { + cleanupPlaybackService(); + } + } + + public void setBufferingStrategy(BufferingStrategy.BufferingStrategyEnum _bufferingStrategy) { + bufferingStrategy = _bufferingStrategy; + } + + public boolean getPreventsDisplaySleepDuringVideoPlayback() { + return preventsDisplaySleepDuringVideoPlayback; + } + + public void setDisableDisconnectError(boolean disableDisconnectError) { + this.disableDisconnectError = disableDisconnectError; + } + + public void setFullscreen(boolean fullscreen) { + if (fullscreen == isFullscreen) { + return; // Avoid generating events when nothing is changing + } + isFullscreen = fullscreen; + + Activity activity = themedReactContext.getCurrentActivity(); + if (activity == null) { + return; + } + + if (isFullscreen) { + fullScreenPlayerView = new FullScreenPlayerView(getContext(), exoPlayerView, this, null, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + setFullscreen(false); + } + }, controlsConfig); + eventEmitter.onVideoFullscreenPlayerWillPresent.invoke(); + if (fullScreenPlayerView != null) { + fullScreenPlayerView.show(); + } + UiThreadUtil.runOnUiThread(() -> { + eventEmitter.onVideoFullscreenPlayerDidPresent.invoke(); + }); + } else { + eventEmitter.onVideoFullscreenPlayerWillDismiss.invoke(); + if (fullScreenPlayerView != null) { + fullScreenPlayerView.dismiss(); + reLayoutControls(); + setControls(controls); + } + UiThreadUtil.runOnUiThread(() -> { + eventEmitter.onVideoFullscreenPlayerDidDismiss.invoke(); + }); + } + } + + @Override + public void onDrmKeysLoaded(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { + DebugLog.d("DRM Info", "onDrmKeysLoaded"); + } + + @Override + public void onDrmSessionAcquired(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, int state) { + DebugLog.d("DRM Info", "onDrmSessionAcquired"); + } + + @Override + public void onDrmSessionReleased(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { + DebugLog.d("DRM Info", "onDrmSessionReleased"); + } + + @Override + public void onDrmSessionManagerError(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, @NonNull Exception e) { + DebugLog.d("DRM Info", "onDrmSessionManagerError"); + eventEmitter.onVideoError.invoke("onDrmSessionManagerError", e, "3002"); + } + + @Override + public void onDrmKeysRestored(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { + DebugLog.d("DRM Info", "onDrmKeysRestored"); + } + + @Override + public void onDrmKeysRemoved(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { + DebugLog.d("DRM Info", "onDrmKeysRemoved"); + } + + /** + * Handling controls prop + * + * @param controls Controls prop, if true enable controls, if false disable them + */ + public void setControls(boolean controls) { + this.controls = controls; + if (exoPlayerView != null) { + exoPlayerView.setUseController(controls); + // Additional configuration for proper touch handling + if (controls) { + exoPlayerView.setControllerAutoShow(true); + exoPlayerView.setControllerHideOnTouch(true); // Show controls on touch, don't hide + exoPlayerView.setControllerShowTimeoutMs(5000); + } + } + if (controls) { + addPlayerControl(); + } + refreshControlsStyles(); + } + + public void setSubtitleStyle(SubtitleStyle style) { + exoPlayerView.setSubtitleStyle(style); + } + + public void setShutterColor(Integer color) { + exoPlayerView.setShutterColor(color); + } + + @Override + public void onAdEvent(AdEvent adEvent) { + if (adEvent.getAdData() != null) { + eventEmitter.onReceiveAdEvent.invoke(adEvent.getType().name(), adEvent.getAdData()); + } else { + eventEmitter.onReceiveAdEvent.invoke(adEvent.getType().name(), null); + } + } + + @Override + public void onAdError(AdErrorEvent adErrorEvent) { + AdError error = adErrorEvent.getError(); + Map errMap = Map.of( + "message", error.getMessage(), + "code", String.valueOf(error.getErrorCode()), + "type", String.valueOf(error.getErrorType()) + ); + eventEmitter.onReceiveAdEvent.invoke("ERROR", errMap); + } + + public void setControlsStyles(ControlsConfig controlsStyles) { + controlsConfig = controlsStyles; + refreshControlsStyles(); + } +} diff --git a/package-lock.json b/package-lock.json index e92aa7a03..3b98bc07a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@expo/metro-runtime": "~4.0.1", "@expo/vector-icons": "~14.0.4", "@lottiefiles/dotlottie-react": "^0.6.5", - "@react-native-async-storage/async-storage": "^1.23.1", + "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/blur": "^4.4.1", "@react-native-community/netinfo": "^11.4.1", "@react-native-community/slider": "4.5.5", @@ -24,7 +24,7 @@ "@react-navigation/native-stack": "^7.3.10", "@react-navigation/stack": "^7.2.10", "@sentry/react-native": "~6.10.0", - "@shopify/flash-list": "2.0.3", + "@shopify/flash-list": "^2.0.3", "@supabase/supabase-js": "^2.54.0", "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", @@ -42,7 +42,7 @@ "expo-crypto": "~14.0.2", "expo-dev-client": "~5.0.20", "expo-device": "~7.0.3", - "expo-document-picker": "^14.0.7", + "expo-document-picker": "~13.0.3", "expo-file-system": "~18.0.12", "expo-haptics": "~14.0.1", "expo-image": "~2.0.7", @@ -53,7 +53,7 @@ "expo-notifications": "~0.29.14", "expo-random": "^14.0.1", "expo-screen-orientation": "~8.0.4", - "expo-sharing": "^14.0.7", + "expo-sharing": "~13.0.1", "expo-status-bar": "~2.0.1", "expo-system-ui": "^4.0.9", "expo-updates": "~0.27.4", @@ -69,7 +69,7 @@ "react-native-immersive-mode": "^2.0.2", "react-native-markdown-display": "^7.0.2", "react-native-paper": "^5.13.1", - "react-native-reanimated": "3.17.4", + "react-native-reanimated": "^3.17.4", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", "react-native-svg": "15.8.0", @@ -2464,9 +2464,9 @@ } }, "node_modules/@expo/cli/node_modules/undici": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz", + "integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==", "license": "MIT", "engines": { "node": ">=18.17" @@ -3658,9 +3658,9 @@ } }, "node_modules/@posthog/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.2.1.tgz", - "integrity": "sha512-zNw96BipqM5/Tf161Q8/K5zpwGY3ezfb2wz+Yc3fIT5OQHW8eEzkQldPgtFKMUkqImc73ukEa2IdUpS6vEGH7w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.2.2.tgz", + "integrity": "sha512-f16Ozx6LIigRG+HsJdt+7kgSxZTHeX5f1JlCGKI1lXcvlZgfsCR338FuMI2QRYXGl+jg/vYFzGOTQBxl90lnBg==", "license": "MIT" }, "node_modules/@react-native-async-storage/async-storage": { @@ -5142,12 +5142,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", - "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", + "version": "24.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.2.tgz", + "integrity": "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang==", "license": "MIT", "dependencies": { - "undici-types": "~7.12.0" + "undici-types": "~7.13.0" } }, "node_modules/@types/node-forge": { @@ -5172,9 +5172,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.24", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", - "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", + "version": "18.3.25", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.25.tgz", + "integrity": "sha512-oSVZmGtDPmRZtVDqvdKUi/qgCsWp5IDY29wp8na8Bj4B3cc99hfNzvNhlMkVVxctkAOGUA3Km7MMpBHAnWfcIA==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -6008,9 +6008,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.7.tgz", - "integrity": "sha512-bxxN2M3a4d1CRoQC//IqsR5XrLh0IJ8TCv2x6Y9N0nckNz/rTjZB3//GGscZziZOxmjP55rzxg/ze7usFI9FqQ==", + "version": "2.8.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.12.tgz", + "integrity": "sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -6120,9 +6120,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", - "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", "funding": [ { "type": "opencollective", @@ -6139,9 +6139,9 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.3", - "caniuse-lite": "^1.0.30001741", - "electron-to-chromium": "^1.5.218", + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, @@ -6350,9 +6350,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001745", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", - "integrity": "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==", + "version": "1.0.30001747", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001747.tgz", + "integrity": "sha512-mzFa2DGIhuc5490Nd/G31xN1pnBnYMadtkyTjefPI7wzypqgCEpeWu9bJr0OnDsyKrW75zA9ZAt7pbQFmwLsQg==", "funding": [ { "type": "opencollective", @@ -7326,9 +7326,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.224", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.224.tgz", - "integrity": "sha512-kWAoUu/bwzvnhpdZSIc6KUyvkI1rbRXMT0Eq8pKReyOyaPZcctMli+EgvcN1PAvwVc7Tdo4Fxi2PsLNDU05mdg==", + "version": "1.5.230", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.230.tgz", + "integrity": "sha512-A6A6Fd3+gMdaed9wX83CvHYJb4UuapPD5X5SLq72VZJzxHSY0/LUweGXRWmQlh2ln7KV7iw7jnwXK7dlPoOnHQ==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -7883,9 +7883,9 @@ } }, "node_modules/expo-document-picker": { - "version": "14.0.7", - "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.7.tgz", - "integrity": "sha512-81Jh8RDD0GYBUoSTmIBq30hXXjmkDV1ZY2BNIp1+3HR5PDSh2WmdhD/Ezz5YFsv46hIXHsQc+Kh1q8vn6OLT9Q==", + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-13.0.3.tgz", + "integrity": "sha512-348xcsiA/YhgWm1SuJNNdb5cUDpRJYCyIk8MhOU2MEDxbVRR+Q1TiUBTCIMVqaWHcxsFQzP56Wwv9n24qjeILg==", "license": "MIT", "peerDependencies": { "expo": "*" @@ -7975,9 +7975,9 @@ } }, "node_modules/expo-libvlc-player": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/expo-libvlc-player/-/expo-libvlc-player-2.1.7.tgz", - "integrity": "sha512-WJz5vZE3qcFi2yeSYFWimdUS6oG+/gZZzsuhTvjCJ0pDTWsVeYVvmZbF4fg6cKRZf2WkZTaLzfngfVEBa6+3hA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/expo-libvlc-player/-/expo-libvlc-player-2.2.1.tgz", + "integrity": "sha512-RYp5t+2B5v8b5h1vfQfKwRYi8WjMaZQ6gWYFJJ+Phrx3tND+moTDIvG+OvPn6Uxn9TslzlbX1n6ad9jv/cypDw==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -8144,9 +8144,9 @@ } }, "node_modules/expo-sharing": { - "version": "14.0.7", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.7.tgz", - "integrity": "sha512-t/5tR8ZJNH6tMkHXlF7453UafNIfrpfTG+THN9EMLC4Wsi4bJuESPm3NdmWDg2D4LDALJI/LQo0iEnLAd5Sp4g==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-13.0.1.tgz", + "integrity": "sha512-qych3Nw65wlFcnzE/gRrsdtvmdV0uF4U4qVMZBJYPG90vYyWh2QM9rp1gVu0KWOBc7N8CC2dSVYn4/BXqJy6Xw==", "license": "MIT", "peerDependencies": { "expo": "*" @@ -8506,9 +8506,9 @@ "license": "MIT" }, "node_modules/flow-parser": { - "version": "0.286.0", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.286.0.tgz", - "integrity": "sha512-+tLYO+fxXCYc/0hyOxI3ky0lvVTbzc3jPSnUkoul/L6OCHvrUhVjxLMz1fsYVvVGyFDmlsMut3SVxaqoLBjC9A==", + "version": "0.287.0", + "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.287.0.tgz", + "integrity": "sha512-92XfPmSg6zV/UD/R3Hw+sxBUi3SiIL8COqD7p3HRZysX1ksrw5MdPhpqox0U0Hd5lqQ9F1AJCi92fnRO7WDgFw==", "license": "MIT", "engines": { "node": ">=0.4.0" @@ -8670,6 +8670,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -9416,13 +9425,14 @@ } }, "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -11386,9 +11396,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.21", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", - "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "version": "2.0.23", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", + "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", "license": "MIT" }, "node_modules/node-vibrant": { @@ -11410,9 +11420,9 @@ } }, "node_modules/node-vibrant/node_modules/@types/node": { - "version": "18.19.127", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.127.tgz", - "integrity": "sha512-gSjxjrnKXML/yo0BO099uPixMqfpJU0TKYjpfLU7TrtA2WWDki412Np/RSTPRil1saKBhvVVKzVx/p/6p94nVA==", + "version": "18.19.129", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.129.tgz", + "integrity": "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A==", "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -12205,12 +12215,12 @@ "license": "MIT" }, "node_modules/posthog-react-native": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.7.0.tgz", - "integrity": "sha512-68ekugxAqKKjS+ZDWjmbnHWWTSRU436RAwylvrWCP6JU4LVMIBD/mqAA3YwgXlTPa9l2ZRz4RboL+CeCx8WgGQ==", + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.7.1.tgz", + "integrity": "sha512-zU11Mswfe/Efjvxg9HbYXugy0mF4VdJ3wymahSJkoJO8zZshDUP3iGcVXrJE1KYqerdkouVcYuu9V4TRHZG1uQ==", "license": "MIT", "dependencies": { - "@posthog/core": "1.2.1" + "@posthog/core": "1.2.2" }, "peerDependencies": { "@react-native-async-storage/async-storage": ">=1.0.0", @@ -12584,9 +12594,9 @@ } }, "node_modules/react-is": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", - "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", + "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", "license": "MIT" }, "node_modules/react-native": { @@ -15084,9 +15094,9 @@ } }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -15129,10 +15139,21 @@ "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", "license": "MIT" }, + "node_modules/undici": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", - "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", + "integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -15278,9 +15299,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index ed2ab4eea..ab7303311 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@expo/metro-runtime": "~4.0.1", "@expo/vector-icons": "~14.0.4", "@lottiefiles/dotlottie-react": "^0.6.5", - "@react-native-async-storage/async-storage": "^1.23.1", + "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/blur": "^4.4.1", "@react-native-community/netinfo": "^11.4.1", "@react-native-community/slider": "4.5.5", @@ -24,7 +24,7 @@ "@react-navigation/native-stack": "^7.3.10", "@react-navigation/stack": "^7.2.10", "@sentry/react-native": "~6.10.0", - "@shopify/flash-list": "2.0.3", + "@shopify/flash-list": "^2.0.3", "@supabase/supabase-js": "^2.54.0", "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", @@ -42,7 +42,7 @@ "expo-crypto": "~14.0.2", "expo-dev-client": "~5.0.20", "expo-device": "~7.0.3", - "expo-document-picker": "^14.0.7", + "expo-document-picker": "~13.0.3", "expo-file-system": "~18.0.12", "expo-haptics": "~14.0.1", "expo-image": "~2.0.7", @@ -53,7 +53,7 @@ "expo-notifications": "~0.29.14", "expo-random": "^14.0.1", "expo-screen-orientation": "~8.0.4", - "expo-sharing": "^14.0.7", + "expo-sharing": "~13.0.1", "expo-status-bar": "~2.0.1", "expo-system-ui": "^4.0.9", "expo-updates": "~0.27.4", @@ -69,7 +69,7 @@ "react-native-immersive-mode": "^2.0.2", "react-native-markdown-display": "^7.0.2", "react-native-paper": "^5.13.1", - "react-native-reanimated": "3.17.4", + "react-native-reanimated": "^3.17.4", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", "react-native-svg": "15.8.0",