From a2346abd69bd83b3a2be9f897bd2177c4aeae5f6 Mon Sep 17 00:00:00 2001 From: Erik Withak Date: Wed, 20 May 2026 21:36:05 +0000 Subject: [PATCH 1/2] fix(android): merge sideloaded subtitle tracks and guard PiP activity lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProgressiveMediaSource.Factory (and HLS/DASH variants) silently ignore MediaItem.SubtitleConfiguration entries, so external subtitle/caption files never appeared in MappedTrackInfo or responded to setSelectedTextTrack. Fix: after the primary MediaSource is built, detect any SubtitleConfiguration entries on the MediaItem, create a dedicated ProgressiveMediaSource for each using SubtitleExtractor, and merge them all with MergingMediaSource. This makes sideloaded tracks selectable exactly like embedded tracks. Related: findActivity() previously threw IllegalStateException when the ComponentActivity wasn't reachable through the ContextWrapper chain — a condition that arises when PiP lifecycle events fire after the subtitle MergingMediaSource changes the ExoPlayer session state. Make it return null with a ThemedReactContext.getCurrentActivity() fallback; update all call sites to handle null gracefully. Also replace the deprecated context.currentActivity field with context.getCurrentActivity(). Fixes: external subtitles/captions not loading on Android (#4792, #4614) --- .../exoplayer/PictureInPictureUtil.kt | 21 ++++++++++----- .../exoplayer/ReactExoplayerView.java | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/android/src/main/java/com/brentvatne/exoplayer/PictureInPictureUtil.kt b/android/src/main/java/com/brentvatne/exoplayer/PictureInPictureUtil.kt index 5e36ef2582..3eba51dd75 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/PictureInPictureUtil.kt +++ b/android/src/main/java/com/brentvatne/exoplayer/PictureInPictureUtil.kt @@ -24,13 +24,18 @@ import com.brentvatne.common.toolbox.DebugLog import com.brentvatne.receiver.PictureInPictureReceiver import com.facebook.react.uimanager.ThemedReactContext -internal fun Context.findActivity(): ComponentActivity { +internal fun Context.findActivity(): ComponentActivity? { var context = this while (context is ContextWrapper) { if (context is ComponentActivity) return context context = context.baseContext } - throw IllegalStateException("Picture in picture should be called in the context of an Activity") + // Fallback: ThemedReactContext stores the activity separately from the ContextWrapper chain + if (this is ThemedReactContext) { + val activity = this.getCurrentActivity() + if (activity is ComponentActivity) return activity + } + return null } object PictureInPictureUtil { @@ -39,7 +44,7 @@ object PictureInPictureUtil { @JvmStatic fun addLifecycleEventListener(context: ThemedReactContext, view: ReactExoplayerView): Runnable { - val activity = context.findActivity() + val activity = context.findActivity() ?: return Runnable {} val onPictureInPictureModeChanged = Consumer { info -> view.setIsInPictureInPicture(info.isInPictureInPictureMode) @@ -73,16 +78,17 @@ object PictureInPictureUtil { @JvmStatic fun enterPictureInPictureMode(context: ThemedReactContext, pictureInPictureParams: PictureInPictureParams?) { if (!isSupportPictureInPicture(context)) return + val activity = context.findActivity() ?: return if (isSupportPictureInPictureAction() && pictureInPictureParams != null) { try { - context.findActivity().enterPictureInPictureMode(pictureInPictureParams) + activity.enterPictureInPictureMode(pictureInPictureParams) } catch (e: IllegalStateException) { DebugLog.e(TAG, e.toString()) } } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { try { @Suppress("DEPRECATION") - context.findActivity().enterPictureInPictureMode() + activity.enterPictureInPictureMode() } catch (e: IllegalStateException) { DebugLog.e(TAG, e.toString()) } @@ -119,8 +125,9 @@ object PictureInPictureUtil { private fun updatePictureInPictureActions(context: ThemedReactContext, pipParams: PictureInPictureParams) { if (!isSupportPictureInPictureAction()) return if (!isSupportPictureInPicture(context)) return + val activity = context.findActivity() ?: return try { - context.findActivity().setPictureInPictureParams(pipParams) + activity.setPictureInPictureParams(pipParams) } catch (e: IllegalStateException) { DebugLog.e(TAG, e.toString()) } @@ -196,7 +203,7 @@ object PictureInPictureUtil { } private fun checkIsUserAllowPIP(context: ThemedReactContext): Boolean { - val activity = context.currentActivity ?: return false + val activity = context.getCurrentActivity() ?: return false return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @SuppressLint("InlinedApi") val result = AppOpsManagerCompat.noteOpNoThrow( diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index e16ac96d8a..4e08d0dc65 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -88,6 +88,9 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.exoplayer.source.MergingMediaSource; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.text.DefaultSubtitleParserFactory; +import androidx.media3.extractor.text.SubtitleExtractor; import androidx.media3.exoplayer.source.ProgressiveMediaSource; import androidx.media3.exoplayer.source.TrackGroupArray; import androidx.media3.exoplayer.source.ads.AdsMediaSource; @@ -1151,6 +1154,30 @@ private MediaSource buildMediaSource(Uri uri, String overrideExtension, DrmSessi ) .createMediaSource(mediaItem); + // ProgressiveMediaSource.Factory (and HLS/DASH variants) silently ignore + // MediaItem.SubtitleConfiguration entries. Manually merge sideloaded subtitle + // sources so they appear in MappedTrackInfo and can be selected via setSelectedTextTrack. + if (mediaItem.localConfiguration != null && !mediaItem.localConfiguration.subtitleConfigurations.isEmpty()) { + DataSource.Factory subtitleDataSourceFactory = buildDataSourceFactory(false); + DefaultSubtitleParserFactory subtitleParserFactory = new DefaultSubtitleParserFactory(); + int subtitleCount = mediaItem.localConfiguration.subtitleConfigurations.size(); + MediaSource[] mergedSources = new MediaSource[1 + subtitleCount]; + mergedSources[0] = mediaSource; + for (int i = 0; i < subtitleCount; i++) { + MediaItem.SubtitleConfiguration subtitleConfig = mediaItem.localConfiguration.subtitleConfigurations.get(i); + Format subtitleFormat = new Format.Builder() + .setSampleMimeType(subtitleConfig.mimeType) + .setLanguage(subtitleConfig.language) + .setLabel(subtitleConfig.label) + .build(); + mergedSources[i + 1] = new ProgressiveMediaSource.Factory( + subtitleDataSourceFactory, + () -> new Extractor[]{new SubtitleExtractor(subtitleParserFactory.create(subtitleFormat), subtitleFormat)} + ).createMediaSource(MediaItem.fromUri(subtitleConfig.uri)); + } + mediaSource = new MergingMediaSource(mergedSources); + } + if (cropStartMs >= 0 && cropEndMs >= 0) { return new ClippingMediaSource(mediaSource, cropStartMs * 1000, cropEndMs * 1000); } else if (cropStartMs >= 0) { From 624e8d197ed1dabc3d56ca764e4970c2fdb2ba5f Mon Sep 17 00:00:00 2001 From: Erik Withak Date: Wed, 20 May 2026 23:36:32 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(android):=20harden=20subtitle=20merge?= =?UTF-8?q?=20=E2=80=94=20strip=20configs=20from=20base=20item,=20guard=20?= =?UTF-8?q?unsupported=20formats,=20preserve=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three improvements over the initial subtitle fix: - Strip SubtitleConfigurations from the MediaItem before passing it to mediaSourceFactory so no factory variant (ProgressiveMediaSource, DefaultMediaSourceFactory, or plugin overrides) can create its own legacy subtitle sources alongside our MergingMediaSource. Prevents double-processing and the Media3 1.8 'Legacy decoding is disabled' crash that occurs when both paths are active. - Check subtitleParserFactory.supportsFormat() before calling create(). Previously an unknown mimeType would throw IllegalArgumentException at source creation time, killing playback entirely; now we log a warning and skip the unsupported track, matching DefaultMediaSourceFactory's behavior. - Carry selectionFlags and roleFlags from SubtitleConfiguration onto the rebuilt Format so default/forced-caption auto-selection and preferred- role selection continue to work correctly. --- .../exoplayer/ReactExoplayerView.java | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index 4e08d0dc65..3fe22bc69e 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -1147,12 +1147,18 @@ private MediaSource buildMediaSource(Uri uri, String overrideExtension, DrmSessi ? overridenMediaItemBuilder.build() : mediaItemBuilder.build(); + // Strip subtitle configurations before handing the MediaItem to the factory so that + // no factory variant (including overridden DefaultMediaSourceFactory) creates its own + // subtitle sources via legacy paths. We handle all subtitle merging below. + MediaItem mediaItemForSource = mediaItem.buildUpon() + .setSubtitleConfigurations(ImmutableList.of()) + .build(); MediaSource mediaSource = mediaSourceFactory .setDrmSessionManagerProvider(drmProvider) .setLoadErrorHandlingPolicy( config.buildLoadErrorHandlingPolicy(source.getMinLoadRetryCount()) ) - .createMediaSource(mediaItem); + .createMediaSource(mediaItemForSource); // ProgressiveMediaSource.Factory (and HLS/DASH variants) silently ignore // MediaItem.SubtitleConfiguration entries. Manually merge sideloaded subtitle @@ -1160,22 +1166,28 @@ private MediaSource buildMediaSource(Uri uri, String overrideExtension, DrmSessi if (mediaItem.localConfiguration != null && !mediaItem.localConfiguration.subtitleConfigurations.isEmpty()) { DataSource.Factory subtitleDataSourceFactory = buildDataSourceFactory(false); DefaultSubtitleParserFactory subtitleParserFactory = new DefaultSubtitleParserFactory(); - int subtitleCount = mediaItem.localConfiguration.subtitleConfigurations.size(); - MediaSource[] mergedSources = new MediaSource[1 + subtitleCount]; - mergedSources[0] = mediaSource; - for (int i = 0; i < subtitleCount; i++) { - MediaItem.SubtitleConfiguration subtitleConfig = mediaItem.localConfiguration.subtitleConfigurations.get(i); + List mergedSources = new ArrayList<>(); + mergedSources.add(mediaSource); + for (MediaItem.SubtitleConfiguration subtitleConfig : mediaItem.localConfiguration.subtitleConfigurations) { Format subtitleFormat = new Format.Builder() .setSampleMimeType(subtitleConfig.mimeType) .setLanguage(subtitleConfig.language) .setLabel(subtitleConfig.label) + .setSelectionFlags(subtitleConfig.selectionFlags) + .setRoleFlags(subtitleConfig.roleFlags) .build(); - mergedSources[i + 1] = new ProgressiveMediaSource.Factory( + if (!subtitleParserFactory.supportsFormat(subtitleFormat)) { + DebugLog.w(TAG, "Skipping sideloaded subtitle track with unsupported format: " + subtitleConfig.mimeType); + continue; + } + mergedSources.add(new ProgressiveMediaSource.Factory( subtitleDataSourceFactory, () -> new Extractor[]{new SubtitleExtractor(subtitleParserFactory.create(subtitleFormat), subtitleFormat)} - ).createMediaSource(MediaItem.fromUri(subtitleConfig.uri)); + ).createMediaSource(MediaItem.fromUri(subtitleConfig.uri))); + } + if (mergedSources.size() > 1) { + mediaSource = new MergingMediaSource(mergedSources.toArray(new MediaSource[0])); } - mediaSource = new MergingMediaSource(mergedSources); } if (cropStartMs >= 0 && cropEndMs >= 0) {