Added perefance for language and quality for autoplaying streams

This commit is contained in:
meilluer 2026-02-17 11:30:08 +05:30
parent 5874a78ce0
commit dee6bd3f52
11 changed files with 1230 additions and 1024 deletions

View file

@ -2,12 +2,12 @@
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv"/> <uses-sdk tools:overrideLibrary="dev.jdtech.mpv"/>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS"/> <uses-permission android:name="android.permission.WRITE_SETTINGS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>

View file

@ -1,4 +1,4 @@
defaults.url=https://sentry.io/ defaults.url=https://sentry.io/
defaults.org=tapframe defaults.org=tapframe
defaults.project=react-native defaults.project=react-native
auth.token=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 # Using SENTRY_AUTH_TOKEN environment variable

View file

@ -1,6 +1,5 @@
package com.brentvatne.common.api package com.brentvatne.common.api
import android.graphics.Color
import com.brentvatne.common.toolbox.ReactBridgeUtils import com.brentvatne.common.toolbox.ReactBridgeUtils
import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableMap
@ -23,17 +22,6 @@ class SubtitleStyle public constructor() {
var subtitlesFollowVideo = true var subtitlesFollowVideo = true
private set private set
// Extended styling (used by ExoPlayerView via Media3 SubtitleView)
// Stored as Android color ints to avoid parsing multiple times.
var textColor: Int? = null
private set
var backgroundColor: Int? = null
private set
var edgeType: String? = null
private set
var edgeColor: Int? = null
private set
companion object { companion object {
private const val PROP_FONT_SIZE_TRACK = "fontSize" private const val PROP_FONT_SIZE_TRACK = "fontSize"
private const val PROP_PADDING_BOTTOM = "paddingBottom" private const val PROP_PADDING_BOTTOM = "paddingBottom"
@ -43,21 +31,6 @@ class SubtitleStyle public constructor() {
private const val PROP_OPACITY = "opacity" private const val PROP_OPACITY = "opacity"
private const val PROP_SUBTITLES_FOLLOW_VIDEO = "subtitlesFollowVideo" private const val PROP_SUBTITLES_FOLLOW_VIDEO = "subtitlesFollowVideo"
// Extended props (optional)
private const val PROP_TEXT_COLOR = "textColor"
private const val PROP_BACKGROUND_COLOR = "backgroundColor"
private const val PROP_EDGE_TYPE = "edgeType"
private const val PROP_EDGE_COLOR = "edgeColor"
private fun parseColorOrNull(value: String?): Int? {
if (value.isNullOrBlank()) return null
return try {
Color.parseColor(value)
} catch (_: IllegalArgumentException) {
null
}
}
@JvmStatic @JvmStatic
fun parse(src: ReadableMap?): SubtitleStyle { fun parse(src: ReadableMap?): SubtitleStyle {
val subtitleStyle = SubtitleStyle() val subtitleStyle = SubtitleStyle()
@ -68,13 +41,6 @@ class SubtitleStyle public constructor() {
subtitleStyle.paddingRight = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_RIGHT, 0) subtitleStyle.paddingRight = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_RIGHT, 0)
subtitleStyle.opacity = ReactBridgeUtils.safeGetFloat(src, PROP_OPACITY, 1f) subtitleStyle.opacity = ReactBridgeUtils.safeGetFloat(src, PROP_OPACITY, 1f)
subtitleStyle.subtitlesFollowVideo = ReactBridgeUtils.safeGetBool(src, PROP_SUBTITLES_FOLLOW_VIDEO, true) subtitleStyle.subtitlesFollowVideo = ReactBridgeUtils.safeGetBool(src, PROP_SUBTITLES_FOLLOW_VIDEO, true)
// Extended styling
subtitleStyle.textColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_TEXT_COLOR, null))
subtitleStyle.backgroundColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_BACKGROUND_COLOR, null))
subtitleStyle.edgeType = ReactBridgeUtils.safeGetString(src, PROP_EDGE_TYPE, null)
subtitleStyle.edgeColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_EDGE_COLOR, null))
return subtitleStyle return subtitleStyle
} }
} }

View file

@ -10,14 +10,11 @@ import android.widget.FrameLayout
import android.widget.TextView import android.widget.TextView
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Timeline import androidx.media3.common.Timeline
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import com.brentvatne.common.api.ResizeMode import com.brentvatne.common.api.ResizeMode
import com.brentvatne.common.api.SubtitleStyle import com.brentvatne.common.api.SubtitleStyle
@ -55,58 +52,15 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
} }
/**
* Subtitles rendered in a full-size overlay (NOT inside PlayerView's content frame).
* This keeps subtitles anchored in-place even when the video surface/content frame moves
* due to aspect ratio / resizeMode changes.
*
* Controlled by SubtitleStyle.subtitlesFollowVideo.
*/
private val overlaySubtitleView = SubtitleView(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
visibility = View.GONE
// We control styling via SubtitleStyle; don't pull Android system caption defaults.
setApplyEmbeddedStyles(true)
setApplyEmbeddedFontSizes(true)
}
private fun updateSubtitleRenderingMode() {
val internalSubtitleView = playerView.subtitleView
val followVideo = localStyle.subtitlesFollowVideo
val shouldShow = localStyle.opacity != 0.0f
if (followVideo) {
internalSubtitleView?.visibility = if (shouldShow) View.VISIBLE else View.GONE
overlaySubtitleView.visibility = View.GONE
} else {
// Hard-disable PlayerView's internal subtitle view. PlayerView can recreate/toggle this view
// during resize/layout, so we re-assert this in multiple lifecycle points.
internalSubtitleView?.visibility = View.GONE
internalSubtitleView?.alpha = 0f
overlaySubtitleView.visibility = if (shouldShow) View.VISIBLE else View.GONE
overlaySubtitleView.alpha = 1f
}
}
init { init {
// Add PlayerView with explicit layout parameters // Add PlayerView with explicit layout parameters
val playerViewLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) val playerViewLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
addView(playerView, playerViewLayoutParams) addView(playerView, playerViewLayoutParams)
// Add overlay subtitles above PlayerView (so it doesn't move with video content frame)
val subtitleOverlayLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
addView(overlaySubtitleView, subtitleOverlayLayoutParams)
// Add live badge with its own layout parameters // Add live badge with its own layout parameters
val liveBadgeLayoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) val liveBadgeLayoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
liveBadgeLayoutParams.setMargins(16, 16, 16, 16) liveBadgeLayoutParams.setMargins(16, 16, 16, 16)
addView(liveBadge, liveBadgeLayoutParams) addView(liveBadge, liveBadgeLayoutParams)
// PlayerView may internally recreate its subtitle view during relayouts (e.g. resizeMode changes).
// Ensure our rendering mode is re-applied whenever PlayerView lays out.
playerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
updateSubtitleRenderingMode()
}
} }
fun setPlayer(player: ExoPlayer?) { fun setPlayer(player: ExoPlayer?) {
@ -126,10 +80,6 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
playerView.resizeMode = resizeMode playerView.resizeMode = resizeMode
} }
} }
// Re-assert subtitle rendering mode for the current style.
updateSubtitleRenderingMode()
applySubtitleStyle(localStyle)
} }
fun getPlayerView(): PlayerView = playerView fun getPlayerView(): PlayerView = playerView
@ -158,63 +108,23 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
} }
fun setSubtitleStyle(style: SubtitleStyle) { fun setSubtitleStyle(style: SubtitleStyle) {
localStyle = style
applySubtitleStyle(localStyle)
}
private fun applySubtitleStyle(style: SubtitleStyle) {
updateSubtitleRenderingMode()
playerView.subtitleView?.let { subtitleView -> playerView.subtitleView?.let { subtitleView ->
// Important: // Reset to defaults
// Avoid inheriting Android system caption settings via setUserDefaultStyle(), subtitleView.setUserDefaultStyle()
// because those can force a background/window that the app doesn't want.
val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor
val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT
val resolvedEdgeColor = style.edgeColor ?: Color.BLACK
val resolvedEdgeType = when (style.edgeType?.lowercase()) {
"outline" -> CaptionStyleCompat.EDGE_TYPE_OUTLINE
"shadow" -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
else -> CaptionStyleCompat.EDGE_TYPE_NONE
}
// windowColor MUST be transparent to avoid the "caption window" background.
val captionStyle = CaptionStyleCompat(
resolvedTextColor,
resolvedBackgroundColor,
Color.TRANSPARENT,
resolvedEdgeType,
resolvedEdgeColor,
null
)
subtitleView.setStyle(captionStyle)
// Text size: if not provided, fall back to user default size.
if (style.fontSize > 0) {
// Use DIP so the value matches React Native's dp-based fontSize more closely.
// SP would multiply by system fontScale and makes "30" look larger than RN "30".
subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
} else {
subtitleView.setUserDefaultTextSize() subtitleView.setUserDefaultTextSize()
// Apply custom styling
if (style.fontSize > 0) {
subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, style.fontSize.toFloat())
} }
// Horizontal padding is still useful (safe area); vertical offset is handled via bottomPaddingFraction.
subtitleView.setPadding( subtitleView.setPadding(
style.paddingLeft, style.paddingLeft,
style.paddingTop, style.paddingTop,
style.paddingRight, style.paddingRight,
0 style.paddingBottom
) )
// Bottom offset for *internal* subtitles:
// Use Media3 SubtitleView's bottomPaddingFraction (moves cues up) rather than raw view padding.
if (style.paddingBottom > 0 && playerView.height > 0) {
val fraction = (style.paddingBottom.toFloat() / playerView.height.toFloat())
.coerceIn(0f, 0.9f)
subtitleView.setBottomPaddingFraction(fraction)
}
if (style.opacity != 0.0f) { if (style.opacity != 0.0f) {
subtitleView.alpha = style.opacity subtitleView.alpha = style.opacity
subtitleView.visibility = android.view.View.VISIBLE subtitleView.visibility = android.view.View.VISIBLE
@ -222,59 +132,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
subtitleView.visibility = android.view.View.GONE subtitleView.visibility = android.view.View.GONE
} }
} }
localStyle = style
// Apply the same styling to the overlay subtitle view.
run {
val subtitleView = overlaySubtitleView
val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor
val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT
val resolvedEdgeColor = style.edgeColor ?: Color.BLACK
val resolvedEdgeType = when (style.edgeType?.lowercase()) {
"outline" -> CaptionStyleCompat.EDGE_TYPE_OUTLINE
"shadow" -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
else -> CaptionStyleCompat.EDGE_TYPE_NONE
}
val captionStyle = CaptionStyleCompat(
resolvedTextColor,
resolvedBackgroundColor,
Color.TRANSPARENT,
resolvedEdgeType,
resolvedEdgeColor,
null
)
subtitleView.setStyle(captionStyle)
if (style.fontSize > 0) {
// Use DIP so the value matches React Native's dp-based fontSize more closely.
subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
} else {
subtitleView.setUserDefaultTextSize()
}
subtitleView.setPadding(
style.paddingLeft,
style.paddingTop,
style.paddingRight,
0
)
// Bottom offset relative to the full view height (stable even when video content frame moves).
val h = height.takeIf { it > 0 } ?: subtitleView.height
if (style.paddingBottom > 0 && h > 0) {
val fraction = (style.paddingBottom.toFloat() / h.toFloat())
.coerceIn(0f, 0.9f)
subtitleView.setBottomPaddingFraction(fraction)
} else {
subtitleView.setBottomPaddingFraction(0f)
}
if (style.opacity != 0.0f) {
subtitleView.alpha = style.opacity
}
}
} }
fun setShutterColor(color: Int) { fun setShutterColor(color: Int) {
@ -365,13 +223,6 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
} }
private val playerListener = object : Player.Listener { private val playerListener = object : Player.Listener {
override fun onCues(cueGroup: CueGroup) {
// Keep overlay subtitles in sync. This does NOT interfere with PlayerView's own subtitle rendering.
// When subtitlesFollowVideo=false, overlaySubtitleView is the visible one.
updateSubtitleRenderingMode()
overlaySubtitleView.setCues(cueGroup.cues)
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) { override fun onTimelineChanged(timeline: Timeline, reason: Int) {
playerView.post { playerView.post {
playerView.requestLayout() playerView.requestLayout()
@ -433,9 +284,6 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
pendingResizeMode?.let { resizeMode -> pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode playerView.resizeMode = resizeMode
} }
// Re-apply bottomPaddingFraction once we have a concrete height.
updateSubtitleRenderingMode()
applySubtitleStyle(localStyle)
} }
} }
} }

View file

@ -228,8 +228,7 @@ public class ReactExoplayerView extends FrameLayout implements
private ArrayList<Integer> rootViewChildrenOriginalVisibility = new ArrayList<Integer>(); private ArrayList<Integer> rootViewChildrenOriginalVisibility = new ArrayList<Integer>();
/* /*
* When user is seeking first called is on onPositionDiscontinuity -> * When user is seeking first called is on onPositionDiscontinuity -> DISCONTINUITY_REASON_SEEK
* DISCONTINUITY_REASON_SEEK
* Then we set if to false when playback is back in onIsPlayingChanged -> true * Then we set if to false when playback is back in onIsPlayingChanged -> true
*/ */
private boolean isSeeking = false; private boolean isSeeking = false;
@ -299,8 +298,7 @@ public class ReactExoplayerView extends FrameLayout implements
lastPos = pos; lastPos = pos;
lastBufferDuration = bufferedDuration; lastBufferDuration = bufferedDuration;
lastDuration = duration; lastDuration = duration;
eventEmitter.onVideoProgress.invoke(pos, bufferedDuration, player.getDuration(), eventEmitter.onVideoProgress.invoke(pos, bufferedDuration, player.getDuration(), getPositionInFirstPeriodMsForCurrentWindow(pos));
getPositionInFirstPeriodMsForCurrentWindow(pos));
} }
} }
} }
@ -357,9 +355,9 @@ public class ReactExoplayerView extends FrameLayout implements
LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT); LayoutParams.MATCH_PARENT);
exoPlayerView = new ExoPlayerView(getContext()); exoPlayerView = new ExoPlayerView(getContext());
exoPlayerView.addOnLayoutChangeListener( exoPlayerView.addOnLayoutChangeListener( (View v, int l, int t, int r, int b, int ol, int ot, int or, int ob) ->
(View v, int l, int t, int r, int b, int ol, int ot, int or, int ob) -> PictureInPictureUtil PictureInPictureUtil.applySourceRectHint(themedReactContext, pictureInPictureParamsBuilder, exoPlayerView)
.applySourceRectHint(themedReactContext, pictureInPictureParamsBuilder, exoPlayerView)); );
exoPlayerView.setLayoutParams(layoutParams); exoPlayerView.setLayoutParams(layoutParams);
addView(exoPlayerView, 0, layoutParams); addView(exoPlayerView, 0, layoutParams);
@ -385,10 +383,8 @@ public class ReactExoplayerView extends FrameLayout implements
public void onHostPause() { public void onHostPause() {
isInBackground = true; isInBackground = true;
Activity activity = themedReactContext.getCurrentActivity(); Activity activity = themedReactContext.getCurrentActivity();
boolean isInPictureInPicture = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null boolean isInPictureInPicture = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null && activity.isInPictureInPictureMode();
&& activity.isInPictureInPictureMode(); boolean isInMultiWindowMode = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null && activity.isInMultiWindowMode();
boolean isInMultiWindowMode = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null
&& activity.isInMultiWindowMode();
if (playInBackground || isInPictureInPicture || isInMultiWindowMode) { if (playInBackground || isInPictureInPicture || isInMultiWindowMode) {
return; return;
} }
@ -415,8 +411,7 @@ public class ReactExoplayerView extends FrameLayout implements
eventEmitter.onVideoBandwidthUpdate.invoke(bitrate, 0, 0, null); eventEmitter.onVideoBandwidthUpdate.invoke(bitrate, 0, 0, null);
} else { } else {
Format videoFormat = player.getVideoFormat(); Format videoFormat = player.getVideoFormat();
boolean isRotatedContent = videoFormat != null boolean isRotatedContent = videoFormat != null && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270);
&& (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270);
int width = videoFormat != null ? (isRotatedContent ? videoFormat.height : videoFormat.width) : 0; int width = videoFormat != null ? (isRotatedContent ? videoFormat.height : videoFormat.width) : 0;
int height = videoFormat != null ? (isRotatedContent ? videoFormat.width : videoFormat.height) : 0; int height = videoFormat != null ? (isRotatedContent ? videoFormat.width : videoFormat.height) : 0;
String trackId = videoFormat != null ? videoFormat.id : null; String trackId = videoFormat != null ? videoFormat.id : null;
@ -431,8 +426,7 @@ public class ReactExoplayerView extends FrameLayout implements
* Toggling the visibility of the player control view * Toggling the visibility of the player control view
*/ */
private void togglePlayerControlVisibility() { private void togglePlayerControlVisibility() {
if (player == null) if (player == null) return;
return;
if (exoPlayerView.isControllerVisible()) { if (exoPlayerView.isControllerVisible()) {
exoPlayerView.hideController(); exoPlayerView.hideController();
} else { } else {
@ -456,8 +450,7 @@ public class ReactExoplayerView extends FrameLayout implements
} }
private void updateControllerConfig() { private void updateControllerConfig() {
if (exoPlayerView == null) if (exoPlayerView == null) return;
return;
exoPlayerView.setControllerShowTimeoutMs(5000); exoPlayerView.setControllerShowTimeoutMs(5000);
@ -468,8 +461,7 @@ public class ReactExoplayerView extends FrameLayout implements
} }
private void updateControllerVisibility() { private void updateControllerVisibility() {
if (exoPlayerView == null) if (exoPlayerView == null) return;
return;
exoPlayerView.setUseController(controls && !controlsConfig.getHideFullscreen()); exoPlayerView.setUseController(controls && !controlsConfig.getHideFullscreen());
} }
@ -505,10 +497,8 @@ public class ReactExoplayerView extends FrameLayout implements
speed = 2.0f; speed = 2.0f;
break; break;
default: default:
speed = 1.0f; speed = 1.0f;;
; };
}
;
setRateModifier(speed); setRateModifier(speed);
}); });
builder.show(); builder.show();
@ -520,30 +510,24 @@ public class ReactExoplayerView extends FrameLayout implements
/** /**
* Update the layout * Update the layout
*
* @param view view needs to update layout * @param view view needs to update layout
* *
* This is a workaround for the open bug in react-native: <a href= * This is a workaround for the open bug in react-native: <a href="https://github.com/facebook/react-native/issues/17968">...</a>
* "https://github.com/facebook/react-native/issues/17968">...</a>
*/ */
private void reLayout(View view) { private void reLayout(View view) {
if (view == null) if (view == null) return;
return;
view.measure(MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY), view.measure(MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.EXACTLY)); MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.EXACTLY));
view.layout(view.getLeft(), view.getTop(), view.getMeasuredWidth(), view.getMeasuredHeight()); view.layout(view.getLeft(), view.getTop(), view.getMeasuredWidth(), view.getMeasuredHeight());
} }
private void refreshControlsStyles() { private void refreshControlsStyles() {
if (exoPlayerView == null || player == null || !controls) if (exoPlayerView == null || player == null || !controls) return;
return;
updateControllerVisibility(); updateControllerVisibility();
} }
// Note: The following methods for live content and button visibility are no // Note: The following methods for live content and button visibility are no longer needed
// longer needed // as PlayerView handles controls automatically. Some functionality may need to be
// as PlayerView handles controls automatically. Some functionality may need to
// be
// reimplemented using PlayerView's APIs if custom behavior is required. // reimplemented using PlayerView's APIs if custom behavior is required.
private void reLayoutControls() { private void reLayoutControls() {
@ -580,7 +564,6 @@ public class ReactExoplayerView extends FrameLayout implements
private class RNVLoadControl extends DefaultLoadControl { private class RNVLoadControl extends DefaultLoadControl {
private final int availableHeapInBytes; private final int availableHeapInBytes;
private final Runtime runtime; private final Runtime runtime;
public RNVLoadControl(DefaultAllocator allocator, BufferConfig config) { public RNVLoadControl(DefaultAllocator allocator, BufferConfig config) {
super(allocator, super(allocator,
config.getMinBufferMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt() config.getMinBufferMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt()
@ -602,10 +585,8 @@ public class ReactExoplayerView extends FrameLayout implements
: DefaultLoadControl.DEFAULT_BACK_BUFFER_DURATION_MS, : DefaultLoadControl.DEFAULT_BACK_BUFFER_DURATION_MS,
DefaultLoadControl.DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME); DefaultLoadControl.DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME);
runtime = Runtime.getRuntime(); runtime = Runtime.getRuntime();
ActivityManager activityManager = (ActivityManager) themedReactContext ActivityManager activityManager = (ActivityManager) themedReactContext.getSystemService(ThemedReactContext.ACTIVITY_SERVICE);
.getSystemService(ThemedReactContext.ACTIVITY_SERVICE); double maxHeap = config.getMaxHeapAllocationPercent() != BufferConfig.Companion.getBufferConfigPropUnsetDouble()
double maxHeap = config.getMaxHeapAllocationPercent() != BufferConfig.Companion
.getBufferConfigPropUnsetDouble()
? config.getMaxHeapAllocationPercent() ? config.getMaxHeapAllocationPercent()
: DEFAULT_MAX_HEAP_ALLOCATION_PERCENT; : DEFAULT_MAX_HEAP_ALLOCATION_PERCENT;
availableHeapInBytes = (int) Math.floor(activityManager.getMemoryClass() * maxHeap * 1024 * 1024); availableHeapInBytes = (int) Math.floor(activityManager.getMemoryClass() * maxHeap * 1024 * 1024);
@ -625,15 +606,13 @@ public class ReactExoplayerView extends FrameLayout implements
} }
long usedMemory = runtime.totalMemory() - runtime.freeMemory(); long usedMemory = runtime.totalMemory() - runtime.freeMemory();
long freeMemory = runtime.maxMemory() - usedMemory; long freeMemory = runtime.maxMemory() - usedMemory;
double minBufferMemoryReservePercent = source.getBufferConfig() double minBufferMemoryReservePercent = source.getBufferConfig().getMinBufferMemoryReservePercent() != BufferConfig.Companion.getBufferConfigPropUnsetDouble()
.getMinBufferMemoryReservePercent() != BufferConfig.Companion.getBufferConfigPropUnsetDouble()
? source.getBufferConfig().getMinBufferMemoryReservePercent() ? source.getBufferConfig().getMinBufferMemoryReservePercent()
: ReactExoplayerView.DEFAULT_MIN_BUFFER_MEMORY_RESERVE; : ReactExoplayerView.DEFAULT_MIN_BUFFER_MEMORY_RESERVE;
long reserveMemory = (long) minBufferMemoryReservePercent * runtime.maxMemory(); long reserveMemory = (long) minBufferMemoryReservePercent * runtime.maxMemory();
long bufferedMs = bufferedDurationUs / (long) 1000; long bufferedMs = bufferedDurationUs / (long) 1000;
if (reserveMemory > freeMemory && bufferedMs > 2000) { if (reserveMemory > freeMemory && bufferedMs > 2000) {
// We don't have enough memory in reserve so we stop buffering to allow other // We don't have enough memory in reserve so we stop buffering to allow other components to use it instead
// components to use it instead
return false; return false;
} }
if (runtime.freeMemory() == 0) { if (runtime.freeMemory() == 0) {
@ -666,13 +645,13 @@ public class ReactExoplayerView extends FrameLayout implements
// Initialize core configuration and listeners // Initialize core configuration and listeners
initializePlayerCore(self); initializePlayerCore(self);
pipListenerUnsubscribe = PictureInPictureUtil.addLifecycleEventListener(themedReactContext, this); pipListenerUnsubscribe = PictureInPictureUtil.addLifecycleEventListener(themedReactContext, this);
PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, this.enterPictureInPictureOnLeave);
this.enterPictureInPictureOnLeave);
} }
if (!source.isLocalAssetFile() && !source.isAsset() && source.getBufferConfig().getCacheSize() > 0) { if (!source.isLocalAssetFile() && !source.isAsset() && source.getBufferConfig().getCacheSize() > 0) {
RNVSimpleCache.INSTANCE.setSimpleCache( RNVSimpleCache.INSTANCE.setSimpleCache(
this.getContext(), this.getContext(),
source.getBufferConfig().getCacheSize()); source.getBufferConfig().getCacheSize()
);
useCache = true; useCache = true;
} else { } else {
useCache = false; useCache = false;
@ -680,8 +659,7 @@ public class ReactExoplayerView extends FrameLayout implements
if (playerNeedsSource) { if (playerNeedsSource) {
// Will force display of shutter view if needed // Will force display of shutter view if needed
exoPlayerView.invalidateAspectRatio(); exoPlayerView.invalidateAspectRatio();
// DRM session manager creation must be done on a different thread to prevent // DRM session manager creation must be done on a different thread to prevent crashes so we start a new thread
// crashes so we start a new thread
ExecutorService es = Executors.newSingleThreadExecutor(); ExecutorService es = Executors.newSingleThreadExecutor();
es.execute(() -> { es.execute(() -> {
// DRM initialization must run on a different thread // DRM initialization must run on a different thread
@ -690,8 +668,7 @@ public class ReactExoplayerView extends FrameLayout implements
} }
if (activity == null) { if (activity == null) {
DebugLog.e(TAG, "Failed to initialize Player!, null activity"); DebugLog.e(TAG, "Failed to initialize Player!, null activity");
eventEmitter.onVideoError.invoke("Failed to initialize Player!", eventEmitter.onVideoError.invoke("Failed to initialize Player!", new Exception("Current Activity is null!"), "1001");
new Exception("Current Activity is null!"), "1001");
return; return;
} }
@ -744,7 +721,8 @@ public class ReactExoplayerView extends FrameLayout implements
DefaultAllocator allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE); DefaultAllocator allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE);
RNVLoadControl loadControl = new RNVLoadControl( RNVLoadControl loadControl = new RNVLoadControl(
allocator, allocator,
source.getBufferConfig()); source.getBufferConfig()
);
long initialBitrate = source.getBufferConfig().getInitialBitrate(); long initialBitrate = source.getBufferConfig().getInitialBitrate();
if (initialBitrate > 0) { if (initialBitrate > 0) {
@ -752,8 +730,9 @@ public class ReactExoplayerView extends FrameLayout implements
this.bandwidthMeter = config.getBandwidthMeter(); this.bandwidthMeter = config.getBandwidthMeter();
} }
DefaultRenderersFactory renderersFactory = new DefaultRenderersFactory(getContext()) DefaultRenderersFactory renderersFactory =
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER) new DefaultRenderersFactory(getContext())
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF)
.setEnableDecoderFallback(true) .setEnableDecoderFallback(true)
.forceEnableMediaCodecAsynchronousQueueing(); .forceEnableMediaCodecAsynchronousQueueing();
@ -764,13 +743,11 @@ public class ReactExoplayerView extends FrameLayout implements
} else { } else {
mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory); mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory);
mediaSourceFactory.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, mediaSourceFactory.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView.getPlayerView());
exoPlayerView.getPlayerView());
} }
if (useCache && !disableCache) { if (useCache && !disableCache) {
mediaSourceFactory mediaSourceFactory.setDataSourceFactory(RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true)));
.setDataSourceFactory(RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true)));
} }
player = new ExoPlayer.Builder(getContext(), renderersFactory) player = new ExoPlayer.Builder(getContext(), renderersFactory)
@ -807,7 +784,8 @@ public class ReactExoplayerView extends FrameLayout implements
Uri adTagUrl = adProps.getAdTagUrl(); Uri adTagUrl = adProps.getAdTagUrl();
if (adTagUrl != null) { if (adTagUrl != null) {
// Create an AdsLoader. // Create an AdsLoader.
ImaAdsLoader.Builder imaLoaderBuilder = new ImaAdsLoader.Builder(themedReactContext) ImaAdsLoader.Builder imaLoaderBuilder = new ImaAdsLoader
.Builder(themedReactContext)
.setAdEventListener(this) .setAdEventListener(this)
.setAdErrorListener(this); .setAdErrorListener(this);
@ -839,8 +817,7 @@ public class ReactExoplayerView extends FrameLayout implements
} }
try { try {
// First check if there's a custom DRM manager registered through the plugin // First check if there's a custom DRM manager registered through the plugin system
// system
DRMManagerSpec drmManager = ReactNativeVideoManager.Companion.getInstance().getDRMManager(); DRMManagerSpec drmManager = ReactNativeVideoManager.Companion.getInstance().getDRMManager();
if (drmManager == null) { if (drmManager == null) {
// If no custom manager is registered, use the default implementation // If no custom manager is registered, use the default implementation
@ -849,13 +826,11 @@ public class ReactExoplayerView extends FrameLayout implements
DrmSessionManager drmSessionManager = drmManager.buildDrmSessionManager(uuid, drmProps); DrmSessionManager drmSessionManager = drmManager.buildDrmSessionManager(uuid, drmProps);
if (drmSessionManager == null) { if (drmSessionManager == null) {
eventEmitter.onVideoError.invoke("Failed to build DRM session manager", eventEmitter.onVideoError.invoke("Failed to build DRM session manager", new Exception("DRM session manager is null"), "3007");
new Exception("DRM session manager is null"), "3007");
} }
// Allow plugins to override the DrmSessionManager // Allow plugins to override the DrmSessionManager
DrmSessionManager overriddenManager = ReactNativeVideoManager.Companion.getInstance() DrmSessionManager overriddenManager = ReactNativeVideoManager.Companion.getInstance().overrideDrmSessionManager(source, drmSessionManager);
.overrideDrmSessionManager(source, drmSessionManager);
return overriddenManager != null ? overriddenManager : drmSessionManager; return overriddenManager != null ? overriddenManager : drmSessionManager;
} catch (UnsupportedDrmException ex) { } catch (UnsupportedDrmException ex) {
// Unsupported DRM exceptions are handled by the calling method // Unsupported DRM exceptions are handled by the calling method
@ -878,8 +853,7 @@ public class ReactExoplayerView extends FrameLayout implements
} }
/// init DRM /// init DRM
DrmSessionManager drmSessionManager = initializePlayerDrm(); DrmSessionManager drmSessionManager = initializePlayerDrm();
if (drmSessionManager == null && runningSource.getDrmProps() != null if (drmSessionManager == null && runningSource.getDrmProps() != null && runningSource.getDrmProps().getDrmType() != null) {
&& runningSource.getDrmProps().getDrmType() != null) {
// Failed to initialize DRM session manager - cannot continue // Failed to initialize DRM session manager - cannot continue
DebugLog.e(TAG, "Failed to initialize DRM Session Manager Framework!"); DebugLog.e(TAG, "Failed to initialize DRM Session Manager Framework!");
return; return;
@ -936,8 +910,7 @@ public class ReactExoplayerView extends FrameLayout implements
} catch (UnsupportedDrmException e) { } catch (UnsupportedDrmException e) {
int errorStringId = Util.SDK_INT < 18 ? R.string.error_drm_not_supported int errorStringId = Util.SDK_INT < 18 ? R.string.error_drm_not_supported
: (e.reason == UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME : (e.reason == UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME
? R.string.error_drm_unsupported_scheme ? R.string.error_drm_unsupported_scheme : R.string.error_drm_unknown);
: R.string.error_drm_unknown);
eventEmitter.onVideoError.invoke(getResources().getString(errorStringId), e, "3003"); eventEmitter.onVideoError.invoke(getResources().getString(errorStringId), e, "3003");
} }
} }
@ -982,8 +955,7 @@ public class ReactExoplayerView extends FrameLayout implements
if (playbackServiceBinder != null) { if (playbackServiceBinder != null) {
playbackServiceBinder.getService().unregisterPlayer(player); playbackServiceBinder.getService().unregisterPlayer(player);
} }
} catch (Exception ignored) { } catch (Exception ignored) {}
}
playbackServiceBinder = null; playbackServiceBinder = null;
} }
@ -1029,8 +1001,7 @@ public class ReactExoplayerView extends FrameLayout implements
} }
} }
private MediaSource buildMediaSource(Uri uri, String overrideExtension, DrmSessionManager drmSessionManager, private MediaSource buildMediaSource(Uri uri, String overrideExtension, DrmSessionManager drmSessionManager, long cropStartMs, long cropEndMs) {
long cropStartMs, long cropEndMs) {
if (uri == null) { if (uri == null) {
throw new IllegalStateException("Invalid video uri"); throw new IllegalStateException("Invalid video uri");
} }
@ -1062,12 +1033,12 @@ public class ReactExoplayerView extends FrameLayout implements
Uri adTagUrl = source.getAdsProps().getAdTagUrl(); Uri adTagUrl = source.getAdsProps().getAdTagUrl();
if (adTagUrl != null) { if (adTagUrl != null) {
mediaItemBuilder.setAdsConfiguration( mediaItemBuilder.setAdsConfiguration(
new MediaItem.AdsConfiguration.Builder(adTagUrl).build()); new MediaItem.AdsConfiguration.Builder(adTagUrl).build()
);
} }
} }
MediaItem.LiveConfiguration.Builder liveConfiguration = ConfigurationUtils MediaItem.LiveConfiguration.Builder liveConfiguration = ConfigurationUtils.getLiveConfiguration(source.getBufferConfig());
.getLiveConfiguration(source.getBufferConfig());
mediaItemBuilder.setLiveConfiguration(liveConfiguration.build()); mediaItemBuilder.setLiveConfiguration(liveConfiguration.build());
MediaSource.Factory mediaSourceFactory; MediaSource.Factory mediaSourceFactory;
@ -1079,6 +1050,7 @@ public class ReactExoplayerView extends FrameLayout implements
drmProvider = new DefaultDrmSessionManagerProvider(); drmProvider = new DefaultDrmSessionManagerProvider();
} }
switch (type) { switch (type) {
case CONTENT_TYPE_SS: case CONTENT_TYPE_SS:
if(!BuildConfig.USE_EXOPLAYER_SMOOTH_STREAMING) { if(!BuildConfig.USE_EXOPLAYER_SMOOTH_STREAMING) {
@ -1088,7 +1060,8 @@ public class ReactExoplayerView extends FrameLayout implements
mediaSourceFactory = new SsMediaSource.Factory( mediaSourceFactory = new SsMediaSource.Factory(
new DefaultSsChunkSource.Factory(mediaDataSourceFactory), new DefaultSsChunkSource.Factory(mediaDataSourceFactory),
buildDataSourceFactory(false)); buildDataSourceFactory(false)
);
break; break;
case CONTENT_TYPE_DASH: case CONTENT_TYPE_DASH:
if(!BuildConfig.USE_EXOPLAYER_DASH) { if(!BuildConfig.USE_EXOPLAYER_DASH) {
@ -1098,7 +1071,8 @@ public class ReactExoplayerView extends FrameLayout implements
mediaSourceFactory = new DashMediaSource.Factory( mediaSourceFactory = new DashMediaSource.Factory(
new DefaultDashChunkSource.Factory(mediaDataSourceFactory), new DefaultDashChunkSource.Factory(mediaDataSourceFactory),
buildDataSourceFactory(false)); buildDataSourceFactory(false)
);
break; break;
case CONTENT_TYPE_HLS: case CONTENT_TYPE_HLS:
if (!BuildConfig.USE_EXOPLAYER_HLS) { if (!BuildConfig.USE_EXOPLAYER_HLS) {
@ -1113,14 +1087,13 @@ public class ReactExoplayerView extends FrameLayout implements
} }
mediaSourceFactory = new HlsMediaSource.Factory( mediaSourceFactory = new HlsMediaSource.Factory(
dataSourceFactory) dataSourceFactory
.setAllowChunklessPreparation(source.getTextTracksAllowChunklessPreparation()); ).setAllowChunklessPreparation(source.getTextTracksAllowChunklessPreparation());
break; break;
case CONTENT_TYPE_OTHER: case CONTENT_TYPE_OTHER:
if ("asset".equals(uri.getScheme())) { if ("asset".equals(uri.getScheme())) {
try { try {
DataSource.Factory assetDataSourceFactory = DataSourceUtil DataSource.Factory assetDataSourceFactory = DataSourceUtil.buildAssetDataSourceFactory(themedReactContext, uri);
.buildAssetDataSourceFactory(themedReactContext, uri);
mediaSourceFactory = new ProgressiveMediaSource.Factory(assetDataSourceFactory); mediaSourceFactory = new ProgressiveMediaSource.Factory(assetDataSourceFactory);
} catch (Exception e) { } catch (Exception e) {
throw new IllegalStateException("cannot open input file:" + uri); throw new IllegalStateException("cannot open input file:" + uri);
@ -1128,10 +1101,12 @@ public class ReactExoplayerView extends FrameLayout implements
} else if ("file".equals(uri.getScheme()) || } else if ("file".equals(uri.getScheme()) ||
!useCache) { !useCache) {
mediaSourceFactory = new ProgressiveMediaSource.Factory( mediaSourceFactory = new ProgressiveMediaSource.Factory(
mediaDataSourceFactory); mediaDataSourceFactory
);
} else { } else {
mediaSourceFactory = new ProgressiveMediaSource.Factory( mediaSourceFactory = new ProgressiveMediaSource.Factory(
RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true))); RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true))
);
} }
break; break;
@ -1150,19 +1125,20 @@ public class ReactExoplayerView extends FrameLayout implements
if (cmcdConfigurationFactory != null) { if (cmcdConfigurationFactory != null) {
mediaSourceFactory = mediaSourceFactory.setCmcdConfigurationFactory( mediaSourceFactory = mediaSourceFactory.setCmcdConfigurationFactory(
cmcdConfigurationFactory::createCmcdConfiguration); cmcdConfigurationFactory::createCmcdConfiguration
);
} }
mediaSourceFactory = Objects.requireNonNullElse( mediaSourceFactory = Objects.requireNonNullElse(
ReactNativeVideoManager.Companion.getInstance() ReactNativeVideoManager.Companion.getInstance()
.overrideMediaSourceFactory(source, mediaSourceFactory, mediaDataSourceFactory), .overrideMediaSourceFactory(source, mediaSourceFactory, mediaDataSourceFactory),
mediaSourceFactory); mediaSourceFactory
);
mediaItemBuilder.setStreamKeys(streamKeys); mediaItemBuilder.setStreamKeys(streamKeys);
@Nullable @Nullable
final MediaItem.Builder overridenMediaItemBuilder = ReactNativeVideoManager.Companion.getInstance() final MediaItem.Builder overridenMediaItemBuilder = ReactNativeVideoManager.Companion.getInstance().overrideMediaItemBuilder(source, mediaItemBuilder);
.overrideMediaItemBuilder(source, mediaItemBuilder);
MediaItem mediaItem = overridenMediaItemBuilder != null MediaItem mediaItem = overridenMediaItemBuilder != null
? overridenMediaItemBuilder.build() ? overridenMediaItemBuilder.build()
@ -1171,7 +1147,8 @@ public class ReactExoplayerView extends FrameLayout implements
MediaSource mediaSource = mediaSourceFactory MediaSource mediaSource = mediaSourceFactory
.setDrmSessionManagerProvider(drmProvider) .setDrmSessionManagerProvider(drmProvider)
.setLoadErrorHandlingPolicy( .setLoadErrorHandlingPolicy(
config.buildLoadErrorHandlingPolicy(source.getMinLoadRetryCount())) config.buildLoadErrorHandlingPolicy(source.getMinLoadRetryCount())
)
.createMediaSource(mediaItem); .createMediaSource(mediaItem);
if (cropStartMs >= 0 && cropEndMs >= 0) { if (cropStartMs >= 0 && cropEndMs >= 0) {
@ -1206,8 +1183,7 @@ public class ReactExoplayerView extends FrameLayout implements
} }
} }
MediaItem.SubtitleConfiguration.Builder configBuilder = new MediaItem.SubtitleConfiguration.Builder( MediaItem.SubtitleConfiguration.Builder configBuilder = new MediaItem.SubtitleConfiguration.Builder(track.getUri())
track.getUri())
.setId(trackId) .setId(trackId)
.setMimeType(track.getType()) .setMimeType(track.getType())
.setLabel(label) .setLabel(label)
@ -1218,8 +1194,7 @@ public class ReactExoplayerView extends FrameLayout implements
configBuilder.setLanguage(track.getLanguage()); configBuilder.setLanguage(track.getLanguage());
} }
// Set selection flags - make first track default if no specific track is // Set selection flags - make first track default if no specific track is selected
// selected
if (trackIndex == 0 && (textTrackType == null || "disabled".equals(textTrackType))) { if (trackIndex == 0 && (textTrackType == null || "disabled".equals(textTrackType))) {
configBuilder.setSelectionFlags(C.SELECTION_FLAG_DEFAULT); configBuilder.setSelectionFlags(C.SELECTION_FLAG_DEFAULT);
} else { } else {
@ -1229,12 +1204,10 @@ public class ReactExoplayerView extends FrameLayout implements
MediaItem.SubtitleConfiguration subtitleConfiguration = configBuilder.build(); MediaItem.SubtitleConfiguration subtitleConfiguration = configBuilder.build();
subtitleConfigurations.add(subtitleConfiguration); subtitleConfigurations.add(subtitleConfiguration);
DebugLog.d(TAG, DebugLog.d(TAG, "Created subtitle configuration: " + trackId + " - " + label + " (" + track.getType() + ")");
"Created subtitle configuration: " + trackId + " - " + label + " (" + track.getType() + ")");
trackIndex++; trackIndex++;
} catch (Exception e) { } catch (Exception e) {
DebugLog.e(TAG, DebugLog.e(TAG, "Error creating SubtitleConfiguration for URI " + track.getUri() + ": " + e.getMessage());
"Error creating SubtitleConfiguration for URI " + track.getUri() + ": " + e.getMessage());
} }
} }
@ -1303,8 +1276,7 @@ public class ReactExoplayerView extends FrameLayout implements
case AudioManager.AUDIOFOCUS_LOSS: case AudioManager.AUDIOFOCUS_LOSS:
view.hasAudioFocus = false; view.hasAudioFocus = false;
view.eventEmitter.onAudioFocusChanged.invoke(false); view.eventEmitter.onAudioFocusChanged.invoke(false);
// FIXME this pause can cause issue if content doesn't have pause capability // FIXME this pause can cause issue if content doesn't have pause capability (can happen on live channel)
// (can happen on live channel)
if (activity != null) { if (activity != null) {
activity.runOnUiThread(view::pausePlayback); activity.runOnUiThread(view::pausePlayback);
} }
@ -1325,12 +1297,16 @@ public class ReactExoplayerView extends FrameLayout implements
if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) { if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) {
// Lower the volume // Lower the volume
if (!view.muted) { if (!view.muted) {
activity.runOnUiThread(() -> view.player.setVolume(view.audioVolume * 0.8f)); activity.runOnUiThread(() ->
view.player.setVolume(view.audioVolume * 0.8f)
);
} }
} else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) {
// Raise it back to normal // Raise it back to normal
if (!view.muted) { if (!view.muted) {
activity.runOnUiThread(() -> view.player.setVolume(view.audioVolume * 1)); activity.runOnUiThread(() ->
view.player.setVolume(view.audioVolume * 1)
);
} }
} }
} }
@ -1403,8 +1379,7 @@ public class ReactExoplayerView extends FrameLayout implements
/** /**
* Returns a new DataSource factory. * Returns a new DataSource factory.
* *
* @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener to the new
* to the new
* DataSource factory. * DataSource factory.
* @return A new DataSource factory. * @return A new DataSource factory.
*/ */
@ -1416,14 +1391,12 @@ public class ReactExoplayerView extends FrameLayout implements
/** /**
* Returns a new HttpDataSource factory. * Returns a new HttpDataSource factory.
* *
* @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener to the new
* to the new
* DataSource factory. * DataSource factory.
* @return A new HttpDataSource factory. * @return A new HttpDataSource factory.
*/ */
private HttpDataSource.Factory buildHttpDataSourceFactory(boolean useBandwidthMeter) { private HttpDataSource.Factory buildHttpDataSourceFactory(boolean useBandwidthMeter) {
return DataSourceUtil.getDefaultHttpDataSourceFactory(this.themedReactContext, return DataSourceUtil.getDefaultHttpDataSourceFactory(this.themedReactContext, useBandwidthMeter ? bandwidthMeter : null, source.getHeaders());
useBandwidthMeter ? bandwidthMeter : null, source.getHeaders());
} }
// AudioBecomingNoisyListener implementation // AudioBecomingNoisyListener implementation
@ -1440,13 +1413,11 @@ public class ReactExoplayerView extends FrameLayout implements
@Override @Override
public void onEvents(@NonNull Player player, Player.Events events) { public void onEvents(@NonNull Player player, Player.Events events) {
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED) if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED) || events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) {
|| events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) {
int playbackState = player.getPlaybackState(); int playbackState = player.getPlaybackState();
boolean playWhenReady = player.getPlayWhenReady(); boolean playWhenReady = player.getPlayWhenReady();
String text = "onStateChanged: playWhenReady=" + playWhenReady + ", playbackState="; String text = "onStateChanged: playWhenReady=" + playWhenReady + ", playbackState=";
eventEmitter.onPlaybackRateChange eventEmitter.onPlaybackRateChange.invoke(playWhenReady && playbackState == ExoPlayer.STATE_READY ? 1.0f : 0.0f);
.invoke(playWhenReady && playbackState == ExoPlayer.STATE_READY ? 1.0f : 0.0f);
switch (playbackState) { switch (playbackState) {
case Player.STATE_IDLE: case Player.STATE_IDLE:
text += "idle"; text += "idle";
@ -1503,10 +1474,8 @@ public class ReactExoplayerView extends FrameLayout implements
} }
/** /**
* The progress message handler will duplicate recursions of the * The progress message handler will duplicate recursions of the onProgressMessage handler
* onProgressMessage handler * on change of player state from any state to STATE_READY with playWhenReady is true (when
* 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. * the video is not paused). This clears all existing messages.
*/ */
private void clearProgressMessageHandler() { private void clearProgressMessageHandler() {
@ -1526,8 +1495,7 @@ public class ReactExoplayerView extends FrameLayout implements
setSelectedTextTrack(textTrackType, textTrackValue); setSelectedTextTrack(textTrackType, textTrackValue);
} }
Format videoFormat = player.getVideoFormat(); Format videoFormat = player.getVideoFormat();
boolean isRotatedContent = videoFormat != null boolean isRotatedContent = videoFormat != null && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270);
&& (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270);
int width = videoFormat != null ? (isRotatedContent ? videoFormat.height : videoFormat.width) : 0; int width = videoFormat != null ? (isRotatedContent ? videoFormat.height : videoFormat.width) : 0;
int height = videoFormat != null ? (isRotatedContent ? videoFormat.width : videoFormat.height) : 0; int height = videoFormat != null ? (isRotatedContent ? videoFormat.width : videoFormat.height) : 0;
String trackId = videoFormat != null ? videoFormat.id : null; String trackId = videoFormat != null ? videoFormat.id : null;
@ -1541,8 +1509,7 @@ public class ReactExoplayerView extends FrameLayout implements
if (source.getContentStartTime() != -1) { if (source.getContentStartTime() != -1) {
ExecutorService es = Executors.newSingleThreadExecutor(); ExecutorService es = Executors.newSingleThreadExecutor();
es.execute(() -> { es.execute(() -> {
// To prevent ANRs caused by getVideoTrackInfo we run this on a different thread // To prevent ANRs caused by getVideoTrackInfo we run this on a different thread and notify the player only when we're done
// and notify the player only when we're done
ArrayList<VideoTrack> videoTracks = getVideoTrackInfoFromManifest(); ArrayList<VideoTrack> videoTracks = getVideoTrackInfoFromManifest();
if (videoTracks != null) { if (videoTracks != null) {
isUsingContentResolution = true; isUsingContentResolution = true;
@ -1586,6 +1553,7 @@ public class ReactExoplayerView extends FrameLayout implements
TrackSelectionArray selectionArray = player.getCurrentTrackSelections(); TrackSelectionArray selectionArray = player.getCurrentTrackSelections();
TrackSelection selection = selectionArray.get(C.TRACK_TYPE_AUDIO); TrackSelection selection = selectionArray.get(C.TRACK_TYPE_AUDIO);
for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) { for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) {
TrackGroup group = groups.get(groupIndex); TrackGroup group = groups.get(groupIndex);
Format format = group.getFormat(0); Format format = group.getFormat(0);
@ -1611,8 +1579,7 @@ public class ReactExoplayerView extends FrameLayout implements
videoTrack.setHeight(format.height == Format.NO_VALUE ? 0 : format.height); videoTrack.setHeight(format.height == Format.NO_VALUE ? 0 : format.height);
videoTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); videoTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate);
videoTrack.setRotation(format.rotationDegrees); videoTrack.setRotation(format.rotationDegrees);
if (format.codecs != null) if (format.codecs != null) videoTrack.setCodecs(format.codecs);
videoTrack.setCodecs(format.codecs);
videoTrack.setTrackId(format.id == null ? String.valueOf(trackIndex) : format.id); videoTrack.setTrackId(format.id == null ? String.valueOf(trackIndex) : format.id);
videoTrack.setIndex(trackIndex); videoTrack.setIndex(trackIndex);
return videoTrack; return videoTrack;
@ -1649,8 +1616,7 @@ public class ReactExoplayerView extends FrameLayout implements
return this.getVideoTrackInfoFromManifest(0); return this.getVideoTrackInfoFromManifest(0);
} }
// We need retry count to in case where minefest request fails from poor network // We need retry count to in case where minefest request fails from poor network conditions
// conditions
@WorkerThread @WorkerThread
private ArrayList<VideoTrack> getVideoTrackInfoFromManifest(int retryCount) { private ArrayList<VideoTrack> getVideoTrackInfoFromManifest(int retryCount) {
ExecutorService es = Executors.newSingleThreadExecutor(); ExecutorService es = Executors.newSingleThreadExecutor();
@ -1670,15 +1636,13 @@ public class ReactExoplayerView extends FrameLayout implements
int periodCount = manifest.getPeriodCount(); int periodCount = manifest.getPeriodCount();
for (int i = 0; i < periodCount; i++) { for (int i = 0; i < periodCount; i++) {
Period period = manifest.getPeriod(i); Period period = manifest.getPeriod(i);
for (int adaptationIndex = 0; adaptationIndex < period.adaptationSets for (int adaptationIndex = 0; adaptationIndex < period.adaptationSets.size(); adaptationIndex++) {
.size(); adaptationIndex++) {
AdaptationSet adaptation = period.adaptationSets.get(adaptationIndex); AdaptationSet adaptation = period.adaptationSets.get(adaptationIndex);
if (adaptation.type != C.TRACK_TYPE_VIDEO) { if (adaptation.type != C.TRACK_TYPE_VIDEO) {
continue; continue;
} }
boolean hasFoundContentPeriod = false; boolean hasFoundContentPeriod = false;
for (int representationIndex = 0; representationIndex < adaptation.representations for (int representationIndex = 0; representationIndex < adaptation.representations.size(); representationIndex++) {
.size(); representationIndex++) {
Representation representation = adaptation.representations.get(representationIndex); Representation representation = adaptation.representations.get(representationIndex);
Format format = representation.format; Format format = representation.format;
if (isFormatSupported(format)) { if (isFormatSupported(format)) {
@ -1686,8 +1650,7 @@ public class ReactExoplayerView extends FrameLayout implements
break; break;
} }
hasFoundContentPeriod = true; hasFoundContentPeriod = true;
VideoTrack videoTrack = exoplayerVideoTrackToGenericVideoTrack(format, VideoTrack videoTrack = exoplayerVideoTrackToGenericVideoTrack(format, representationIndex);
representationIndex);
videoTracks.add(videoTrack); videoTracks.add(videoTrack);
} }
} }
@ -1717,16 +1680,12 @@ public class ReactExoplayerView extends FrameLayout implements
return null; return null;
} }
private Track exoplayerTrackToGenericTrack(Format format, int trackIndex, TrackSelection selection, private Track exoplayerTrackToGenericTrack(Format format, int trackIndex, TrackSelection selection, TrackGroup group) {
TrackGroup group) {
Track track = new Track(); Track track = new Track();
track.setIndex(trackIndex); track.setIndex(trackIndex);
if (format.sampleMimeType != null) if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType);
track.setMimeType(format.sampleMimeType); if (format.language != null) track.setLanguage(format.language);
if (format.language != null) if (format.label != null) track.setTitle(format.label);
track.setLanguage(format.language);
if (format.label != null)
track.setTitle(format.label);
track.setSelected(isTrackSelected(selection, group, trackIndex)); track.setSelected(isTrackSelected(selection, group, trackIndex));
return track; return track;
} }
@ -1796,8 +1755,7 @@ public class ReactExoplayerView extends FrameLayout implements
track.setLanguage(format.language != null ? format.language : "unknown"); track.setLanguage(format.language != null ? format.language : "unknown");
track.setTitle(format.label != null ? format.label : "Track " + (groupIndex + 1)); track.setTitle(format.label != null ? format.label : "Track " + (groupIndex + 1));
track.setSelected(false); // Don't report selection status - let PlayerView handle it track.setSelected(false); // Don't report selection status - let PlayerView handle it
if (format.sampleMimeType != null) if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType);
track.setMimeType(format.sampleMimeType);
track.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); track.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate);
tracks.add(track); tracks.add(track);
@ -1828,10 +1786,8 @@ public class ReactExoplayerView extends FrameLayout implements
Track textTrack = new Track(); Track textTrack = new Track();
textTrack.setIndex(textTracks.size()); textTrack.setIndex(textTracks.size());
if (format.sampleMimeType != null) if (format.sampleMimeType != null) textTrack.setMimeType(format.sampleMimeType);
textTrack.setMimeType(format.sampleMimeType); if (format.language != null) textTrack.setLanguage(format.language);
if (format.language != null)
textTrack.setLanguage(format.language);
boolean isExternal = format.id != null && format.id.startsWith("external-subtitle-"); boolean isExternal = format.id != null && format.id.startsWith("external-subtitle-");
@ -1865,34 +1821,28 @@ public class ReactExoplayerView extends FrameLayout implements
} }
@Override @Override
public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, @NonNull Player.PositionInfo newPosition, @Player.DiscontinuityReason int reason) {
@NonNull Player.PositionInfo newPosition, @Player.DiscontinuityReason int reason) {
if (reason == Player.DISCONTINUITY_REASON_SEEK) { if (reason == Player.DISCONTINUITY_REASON_SEEK) {
isSeeking = true; isSeeking = true;
seekPosition = newPosition.positionMs; seekPosition = newPosition.positionMs;
if (isUsingContentResolution) { if (isUsingContentResolution) {
// We need to update the selected track to make sure that it still matches user // We need to update the selected track to make sure that it still matches user selection if track list has changed in this period
// selection if track list has changed in this period
setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue);
} }
} }
if (playerNeedsSource) { if (playerNeedsSource) {
// This will only occur if the user has performed a seek whilst in the error // This will only occur if the user has performed a seek whilst in the error state. Update the
// state. Update the // resume position so that if the user then retries, playback will resume from the position to
// resume position so that if the user then retries, playback will resume from
// the position to
// which they seeked. // which they seeked.
updateResumePosition(); updateResumePosition();
} }
if (isUsingContentResolution) { if (isUsingContentResolution) {
// Discontinuity events might have a different track list so we update the // Discontinuity events might have a different track list so we update the selected track
// selected track
setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue);
selectTrackWhenReady = true; selectTrackWhenReady = true;
} }
// When repeat is turned on, reaching the end of the video will not cause a // When repeat is turned on, reaching the end of the video will not cause a state change
// state change
// so we need to explicitly detect it. // so we need to explicitly detect it.
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION
&& player.getRepeatMode() == Player.REPEAT_MODE_ONE) { && player.getRepeatMode() == Player.REPEAT_MODE_ONE) {
@ -1940,17 +1890,15 @@ public class ReactExoplayerView extends FrameLayout implements
updateSubtitleButtonVisibility(); updateSubtitleButtonVisibility();
} }
private boolean hasBuiltInTextTracks() { private boolean hasBuiltInTextTracks() {
if (player == null || trackSelector == null) if (player == null || trackSelector == null) return false;
return false;
MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo();
if (info == null) if (info == null) return false;
return false;
int textRendererIndex = getTrackRendererIndex(C.TRACK_TYPE_TEXT); int textRendererIndex = getTrackRendererIndex(C.TRACK_TYPE_TEXT);
if (textRendererIndex == C.INDEX_UNSET) if (textRendererIndex == C.INDEX_UNSET) return false;
return false;
TrackGroupArray groups = info.getTrackGroups(textRendererIndex); TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
@ -1970,8 +1918,7 @@ public class ReactExoplayerView extends FrameLayout implements
} }
private void updateSubtitleButtonVisibility() { private void updateSubtitleButtonVisibility() {
if (exoPlayerView == null) if (exoPlayerView == null) return;
return;
boolean hasTextTracks = (source.getSideLoadedTextTracks() != null && boolean hasTextTracks = (source.getSideLoadedTextTracks() != null &&
!source.getSideLoadedTextTracks().getTracks().isEmpty()) || !source.getSideLoadedTextTracks().getTracks().isEmpty()) ||
@ -1995,8 +1942,7 @@ public class ReactExoplayerView extends FrameLayout implements
if (isPlaying && isSeeking) { if (isPlaying && isSeeking) {
eventEmitter.onVideoSeek.invoke(player.getCurrentPosition(), seekPosition); eventEmitter.onVideoSeek.invoke(player.getCurrentPosition(), seekPosition);
} }
PictureInPictureUtil.applyPlayingStatus(themedReactContext, pictureInPictureParamsBuilder, PictureInPictureUtil.applyPlayingStatus(themedReactContext, pictureInPictureParamsBuilder, pictureInPictureReceiver, !isPlaying);
pictureInPictureReceiver, !isPlaying);
eventEmitter.onVideoPlaybackStateChanged.invoke(isPlaying, isSeeking); eventEmitter.onVideoPlaybackStateChanged.invoke(isPlaying, isSeeking);
if (isPlaying) { if (isPlaying) {
@ -2015,8 +1961,7 @@ public class ReactExoplayerView extends FrameLayout implements
case PlaybackException.ERROR_CODE_DRM_SYSTEM_ERROR: case PlaybackException.ERROR_CODE_DRM_SYSTEM_ERROR:
case PlaybackException.ERROR_CODE_DRM_UNSPECIFIED: case PlaybackException.ERROR_CODE_DRM_UNSPECIFIED:
if (!hasDrmFailed) { if (!hasDrmFailed) {
// When DRM fails to reach the app level certificate server it will fail with a // 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
// source error so we assume that it is DRM related and try one more time
hasDrmFailed = true; hasDrmFailed = true;
playerNeedsSource = true; playerNeedsSource = true;
updateResumePosition(); updateResumePosition();
@ -2098,16 +2043,14 @@ public class ReactExoplayerView extends FrameLayout implements
boolean isSourceEqual = source.isEquals(this.source); boolean isSourceEqual = source.isEquals(this.source);
hasDrmFailed = false; hasDrmFailed = false;
this.source = source; this.source = source;
final DataSource.Factory tmpMediaDataSourceFactory = DataSourceUtil.getDefaultDataSourceFactory( final DataSource.Factory tmpMediaDataSourceFactory =
this.themedReactContext, bandwidthMeter, DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, bandwidthMeter,
source.getHeaders()); source.getHeaders());
@Nullable @Nullable
final DataSource.Factory overriddenMediaDataSourceFactory = ReactNativeVideoManager.Companion.getInstance() final DataSource.Factory overriddenMediaDataSourceFactory = ReactNativeVideoManager.Companion.getInstance().overrideMediaDataSourceFactory(source, tmpMediaDataSourceFactory);
.overrideMediaDataSourceFactory(source, tmpMediaDataSourceFactory);
this.mediaDataSourceFactory = Objects.requireNonNullElse(overriddenMediaDataSourceFactory, this.mediaDataSourceFactory = Objects.requireNonNullElse(overriddenMediaDataSourceFactory, tmpMediaDataSourceFactory);
tmpMediaDataSourceFactory);
if (source.getCmcdProps() != null) { if (source.getCmcdProps() != null) {
CMCDConfig cmcdConfig = new CMCDConfig(source.getCmcdProps()); CMCDConfig cmcdConfig = new CMCDConfig(source.getCmcdProps());
@ -2126,7 +2069,6 @@ public class ReactExoplayerView extends FrameLayout implements
clearSrc(); clearSrc();
} }
} }
public void clearSrc() { public void clearSrc() {
if (source.getUri() != null) { if (source.getUri() != null) {
if (player != null) { if (player != null) {
@ -2175,8 +2117,7 @@ public class ReactExoplayerView extends FrameLayout implements
} }
public void disableTrack(int rendererIndex) { public void disableTrack(int rendererIndex) {
if (trackSelector == null) if (trackSelector == null) return;
return;
DefaultTrackSelector.Parameters disableParameters = trackSelector.getParameters() DefaultTrackSelector.Parameters disableParameters = trackSelector.getParameters()
.buildUpon() .buildUpon()
@ -2186,8 +2127,7 @@ public class ReactExoplayerView extends FrameLayout implements
} }
private void selectTextTrackInternal(String type, String value) { private void selectTextTrackInternal(String type, String value) {
if (player == null || trackSelector == null) if (player == null || trackSelector == null) return;
return;
DebugLog.d(TAG, "selectTextTrackInternal: type=" + type + ", value=" + value); DebugLog.d(TAG, "selectTextTrackInternal: type=" + type + ", value=" + value);
@ -2207,11 +2147,6 @@ public class ReactExoplayerView extends FrameLayout implements
TrackGroupArray groups = info.getTrackGroups(textRendererIndex); TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
boolean trackFound = false; boolean trackFound = false;
// react-native-video uses a flattened `textTracks` list on the JS side.
// For HLS/DASH, each TrackGroup often contains a single track at index 0,
// so comparing against `trackIndex` alone makes only the first subtitle selectable.
int flattenedIndex = 0;
for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) {
TrackGroup group = groups.get(groupIndex); TrackGroup group = groups.get(groupIndex);
for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { for (int trackIndex = 0; trackIndex < group.length; trackIndex++) {
@ -2224,13 +2159,11 @@ public class ReactExoplayerView extends FrameLayout implements
isMatch = true; isMatch = true;
} else if ("index".equals(type)) { } else if ("index".equals(type)) {
int targetIndex = ReactBridgeUtils.safeParseInt(value, -1); int targetIndex = ReactBridgeUtils.safeParseInt(value, -1);
if (targetIndex == flattenedIndex) { if (targetIndex == trackIndex) {
isMatch = true; isMatch = true;
} }
} }
flattenedIndex++;
if (isMatch) { if (isMatch) {
TrackSelectionOverride override = new TrackSelectionOverride(group, TrackSelectionOverride override = new TrackSelectionOverride(group,
java.util.Arrays.asList(trackIndex)); java.util.Arrays.asList(trackIndex));
@ -2239,8 +2172,7 @@ public class ReactExoplayerView extends FrameLayout implements
break; break;
} }
} }
if (trackFound) if (trackFound) break;
break;
} }
if (!trackFound) { if (!trackFound) {
@ -2266,8 +2198,7 @@ public class ReactExoplayerView extends FrameLayout implements
} }
public void setSelectedTrack(int trackType, String type, String value) { public void setSelectedTrack(int trackType, String type, String value) {
if (player == null || trackSelector == null) if (player == null || trackSelector == null) return;
return;
if (controls) { if (controls) {
return; return;
@ -2341,11 +2272,9 @@ public class ReactExoplayerView extends FrameLayout implements
usingExactMatch = true; usingExactMatch = true;
break; break;
} else if (isUsingContentResolution) { } else if (isUsingContentResolution) {
// When using content resolution rather than ads, we need to try and find the // When using content resolution rather than ads, we need to try and find the closest match if there is no exact match
// closest match if there is no exact match
if (closestFormat != null) { if (closestFormat != null) {
if ((format.bitrate > closestFormat.bitrate || format.height > closestFormat.height) if ((format.bitrate > closestFormat.bitrate || format.height > closestFormat.height) && format.height < height) {
&& format.height < height) {
// Higher quality match // Higher quality match
closestFormat = format; closestFormat = format;
closestTrackIndex = j; closestTrackIndex = j;
@ -2356,8 +2285,7 @@ public class ReactExoplayerView extends FrameLayout implements
} }
} }
} }
// This is a fallback if the new period contains only higher resolutions than // This is a fallback if the new period contains only higher resolutions than the user has selected
// the user has selected
if (closestFormat == null && isUsingContentResolution && !usingExactMatch) { if (closestFormat == null && isUsingContentResolution && !usingExactMatch) {
// No close match found - so we pick the lowest quality // No close match found - so we pick the lowest quality
int minHeight = Integer.MAX_VALUE; int minHeight = Integer.MAX_VALUE;
@ -2380,8 +2308,8 @@ public class ReactExoplayerView extends FrameLayout implements
} }
} else if (trackType == C.TRACK_TYPE_TEXT && Util.SDK_INT > 18) { // Text default } else if (trackType == C.TRACK_TYPE_TEXT && Util.SDK_INT > 18) { // Text default
// Use system settings if possible // Use system settings if possible
CaptioningManager captioningManager = (CaptioningManager) themedReactContext CaptioningManager captioningManager
.getSystemService(Context.CAPTIONING_SERVICE); = (CaptioningManager)themedReactContext.getSystemService(Context.CAPTIONING_SERVICE);
if (captioningManager != null && captioningManager.isEnabled()) { if (captioningManager != null && captioningManager.isEnabled()) {
groupIndex = getGroupIndexForDefaultLocale(groups); groupIndex = getGroupIndexForDefaultLocale(groups);
} }
@ -2436,8 +2364,7 @@ public class ReactExoplayerView extends FrameLayout implements
.setRendererDisabled(rendererIndex, false); .setRendererDisabled(rendererIndex, false);
// Clear existing overrides for this track type to avoid conflicts // Clear existing overrides for this track type to avoid conflicts
// But be careful with audio tracks - don't clear unless explicitly selecting a // But be careful with audio tracks - don't clear unless explicitly selecting a different track
// different track
if (trackType != C.TRACK_TYPE_AUDIO || !type.equals("default")) { if (trackType != C.TRACK_TYPE_AUDIO || !type.equals("default")) {
selectionParameters.clearOverridesOfType(selectionOverride.getType()); selectionParameters.clearOverridesOfType(selectionOverride.getType());
} }
@ -2505,8 +2432,7 @@ public class ReactExoplayerView extends FrameLayout implements
public void setSelectedVideoTrack(String type, String value) { public void setSelectedVideoTrack(String type, String value) {
videoTrackType = type; videoTrackType = type;
videoTrackValue = value; videoTrackValue = value;
if (!loadVideoStarted) if (!loadVideoStarted) setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue);
setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue);
} }
public void setSelectedAudioTrack(String type, String value) { public void setSelectedAudioTrack(String type, String value) {
@ -2537,11 +2463,9 @@ public class ReactExoplayerView extends FrameLayout implements
} }
public void setEnterPictureInPictureOnLeave(boolean enterPictureInPictureOnLeave) { public void setEnterPictureInPictureOnLeave(boolean enterPictureInPictureOnLeave) {
this.enterPictureInPictureOnLeave = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N this.enterPictureInPictureOnLeave = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && enterPictureInPictureOnLeave;
&& enterPictureInPictureOnLeave;
if (player != null) { if (player != null) {
PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, this.enterPictureInPictureOnLeave);
this.enterPictureInPictureOnLeave);
} }
} }
@ -2549,14 +2473,12 @@ public class ReactExoplayerView extends FrameLayout implements
eventEmitter.onPictureInPictureStatusChanged.invoke(isInPictureInPicture); eventEmitter.onPictureInPictureStatusChanged.invoke(isInPictureInPicture);
if (fullScreenPlayerView != null && fullScreenPlayerView.isShowing()) { if (fullScreenPlayerView != null && fullScreenPlayerView.isShowing()) {
if (isInPictureInPicture) if (isInPictureInPicture) fullScreenPlayerView.hideWithoutPlayer();
fullScreenPlayerView.hideWithoutPlayer();
return; return;
} }
Activity currentActivity = themedReactContext.getCurrentActivity(); Activity currentActivity = themedReactContext.getCurrentActivity();
if (currentActivity == null) if (currentActivity == null) return;
return;
View decorView = currentActivity.getWindow().getDecorView(); View decorView = currentActivity.getWindow().getDecorView();
ViewGroup rootView = decorView.findViewById(android.R.id.content); ViewGroup rootView = decorView.findViewById(android.R.id.content);
@ -2592,12 +2514,10 @@ public class ReactExoplayerView extends FrameLayout implements
public void enterPictureInPictureMode() { public void enterPictureInPictureMode() {
PictureInPictureParams _pipParams = null; PictureInPictureParams _pipParams = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ArrayList<RemoteAction> actions = PictureInPictureUtil.getPictureInPictureActions(themedReactContext, ArrayList<RemoteAction> actions = PictureInPictureUtil.getPictureInPictureActions(themedReactContext, isPaused, pictureInPictureReceiver);
isPaused, pictureInPictureReceiver);
pictureInPictureParamsBuilder.setActions(actions); pictureInPictureParamsBuilder.setActions(actions);
if (player.getPlaybackState() == Player.STATE_READY) { if (player.getPlaybackState() == Player.STATE_READY) {
pictureInPictureParamsBuilder pictureInPictureParamsBuilder.setAspectRatio(PictureInPictureUtil.calcPictureInPictureAspectRatio(player));
.setAspectRatio(PictureInPictureUtil.calcPictureInPictureAspectRatio(player));
} }
_pipParams = pictureInPictureParamsBuilder.build(); _pipParams = pictureInPictureParamsBuilder.build();
} }
@ -2606,15 +2526,13 @@ public class ReactExoplayerView extends FrameLayout implements
public void exitPictureInPictureMode() { public void exitPictureInPictureMode() {
Activity currentActivity = themedReactContext.getCurrentActivity(); Activity currentActivity = themedReactContext.getCurrentActivity();
if (currentActivity == null) if (currentActivity == null) return;
return;
View decorView = currentActivity.getWindow().getDecorView(); View decorView = currentActivity.getWindow().getDecorView();
ViewGroup rootView = decorView.findViewById(android.R.id.content); ViewGroup rootView = decorView.findViewById(android.R.id.content);
if (!rootViewChildrenOriginalVisibility.isEmpty()) { if (!rootViewChildrenOriginalVisibility.isEmpty()) {
if (exoPlayerView.getParent().equals(rootView)) if (exoPlayerView.getParent().equals(rootView)) rootView.removeView(exoPlayerView);
rootView.removeView(exoPlayerView);
for (int i = 0; i < rootView.getChildCount(); i++) { for (int i = 0; i < rootView.getChildCount(); i++) {
rootView.getChildAt(i).setVisibility(rootViewChildrenOriginalVisibility.get(i)); rootView.getChildAt(i).setVisibility(rootViewChildrenOriginalVisibility.get(i));
} }
@ -2741,8 +2659,7 @@ public class ReactExoplayerView extends FrameLayout implements
} }
if (isFullscreen) { if (isFullscreen) {
fullScreenPlayerView = new FullScreenPlayerView(getContext(), exoPlayerView, this, null, fullScreenPlayerView = new FullScreenPlayerView(getContext(), exoPlayerView, this, null, new OnBackPressedCallback(true) {
new OnBackPressedCallback(true) {
@Override @Override
public void handleOnBackPressed() { public void handleOnBackPressed() {
setFullscreen(false); setFullscreen(false);
@ -2784,8 +2701,7 @@ public class ReactExoplayerView extends FrameLayout implements
} }
@Override @Override
public void onDrmSessionManagerError(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, public void onDrmSessionManagerError(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, @NonNull Exception e) {
@NonNull Exception e) {
DebugLog.d("DRM Info", "onDrmSessionManagerError"); DebugLog.d("DRM Info", "onDrmSessionManagerError");
eventEmitter.onVideoError.invoke("onDrmSessionManagerError", e, "3002"); eventEmitter.onVideoError.invoke("onDrmSessionManagerError", e, "3002");
} }
@ -2845,7 +2761,8 @@ public class ReactExoplayerView extends FrameLayout implements
Map<String, String> errMap = Map.of( Map<String, String> errMap = Map.of(
"message", error.getMessage(), "message", error.getMessage(),
"code", String.valueOf(error.getErrorCode()), "code", String.valueOf(error.getErrorCode()),
"type", String.valueOf(error.getErrorType())); "type", String.valueOf(error.getErrorType())
);
eventEmitter.onReceiveAdEvent.invoke("ERROR", errMap); eventEmitter.onReceiveAdEvent.invoke("ERROR", errMap);
handleDaiBackupStream(); handleDaiBackupStream();
@ -2879,8 +2796,8 @@ public class ReactExoplayerView extends FrameLayout implements
* @return The configured IMA server-side ad insertion AdsLoader * @return The configured IMA server-side ad insertion AdsLoader
*/ */
private ImaServerSideAdInsertionMediaSource.AdsLoader createAdsLoader() { private ImaServerSideAdInsertionMediaSource.AdsLoader createAdsLoader() {
ImaServerSideAdInsertionMediaSource.AdsLoader.Builder adsLoaderBuilder = new ImaServerSideAdInsertionMediaSource.AdsLoader.Builder( ImaServerSideAdInsertionMediaSource.AdsLoader.Builder adsLoaderBuilder =
getContext(), exoPlayerView.getPlayerView()) new ImaServerSideAdInsertionMediaSource.AdsLoader.Builder(getContext(), exoPlayerView.getPlayerView())
.setAdEventListener(this) .setAdEventListener(this)
.setAdErrorListener(this); .setAdErrorListener(this);
@ -2898,8 +2815,8 @@ public class ReactExoplayerView extends FrameLayout implements
DataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(getContext()); DataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(getContext());
DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(dataSourceFactory); DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(dataSourceFactory);
ImaServerSideAdInsertionMediaSource.Factory adsMediaSourceFactory = new ImaServerSideAdInsertionMediaSource.Factory( ImaServerSideAdInsertionMediaSource.Factory adsMediaSourceFactory =
daiAdsLoader, mediaSourceFactory); new ImaServerSideAdInsertionMediaSource.Factory(daiAdsLoader, mediaSourceFactory);
mediaSourceFactory.setServerSideAdInsertionMediaSourceFactory(adsMediaSourceFactory); mediaSourceFactory.setServerSideAdInsertionMediaSourceFactory(adsMediaSourceFactory);
@ -2933,8 +2850,7 @@ public class ReactExoplayerView extends FrameLayout implements
/** /**
* Requests a DAI stream from Google IMA using the ExoPlayer IMA extension. * Requests a DAI stream from Google IMA using the ExoPlayer IMA extension.
* *
* Builds an SSAI URI based on the provided parameters and sets it on the * Builds an SSAI URI based on the provided parameters and sets it on the player.
* player.
* Supports both VOD (contentSourceId + videoId) and Live (assetKey) streams. * Supports both VOD (contentSourceId + videoId) and Live (assetKey) streams.
* *
* @param runningSource The source containing DAI properties * @param runningSource The source containing DAI properties
@ -2967,8 +2883,7 @@ public class ReactExoplayerView extends FrameLayout implements
.build() .build()
.buildUpon(); .buildUpon();
} else { } else {
throw new IllegalArgumentException( throw new IllegalArgumentException("Either assetKey (for live) or contentSourceId+videoId (for VOD) must be provided");
"Either assetKey (for live) or contentSourceId+videoId (for VOD) must be provided");
} }
Map<String, String> adTagParameters = adsProps.getAdTagParameters(); Map<String, String> adTagParameters = adsProps.getAdTagParameters();
@ -2991,8 +2906,7 @@ public class ReactExoplayerView extends FrameLayout implements
/** /**
* Handles fallback to backup stream when DAI stream fails. * Handles fallback to backup stream when DAI stream fails.
* *
* If a backup stream URI is available in the DAI properties, it cleans up DAI * If a backup stream URI is available in the DAI properties, it cleans up DAI resources
* resources
* and switches to the backup stream. * and switches to the backup stream.
* *
* @return true if backup stream was successfully used, false otherwise * @return true if backup stream was successfully used, false otherwise

1342
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -40,7 +40,7 @@
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"expo": "^54", "expo": "^54.0.33",
"expo-application": "~7.0.7", "expo-application": "~7.0.7",
"expo-auth-session": "~7.0.8", "expo-auth-session": "~7.0.8",
"expo-blur": "~15.0.7", "expo-blur": "~15.0.7",

View file

@ -45,6 +45,8 @@ export interface AppSettings {
tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code) tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code)
episodeLayoutStyle: 'vertical' | 'horizontal'; // Layout style for episode cards episodeLayoutStyle: 'vertical' | 'horizontal'; // Layout style for episode cards
autoplayBestStream: boolean; // Automatically play the best available stream autoplayBestStream: boolean; // Automatically play the best available stream
autoplayPreferredQuality: string; // Preferred quality for autoplay (e.g., '4K', '1080p', '720p', '480p')
autoplayPreferredLanguage: string; // Preferred language for autoplay (e.g., 'English', 'Spanish', 'Any')
// Local scraper settings // Local scraper settings
scraperRepositoryUrl: string; // URL to the scraper repository scraperRepositoryUrl: string; // URL to the scraper repository
enableLocalScrapers: boolean; // Enable/disable local scraper functionality enableLocalScrapers: boolean; // Enable/disable local scraper functionality
@ -135,6 +137,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
tmdbLanguagePreference: 'en', // Default to English tmdbLanguagePreference: 'en', // Default to English
episodeLayoutStyle: 'vertical', // Default to vertical layout for new installs episodeLayoutStyle: 'vertical', // Default to vertical layout for new installs
autoplayBestStream: false, // Disabled by default for user choice autoplayBestStream: false, // Disabled by default for user choice
autoplayPreferredQuality: '1080p', // Default to 1080p
autoplayPreferredLanguage: 'Any', // Default to Any language
// Local scraper defaults // Local scraper defaults
scraperRepositoryUrl: '', scraperRepositoryUrl: '',
enableLocalScrapers: true, enableLocalScrapers: true,

View file

@ -14,6 +14,7 @@
"try_again": "Try Again", "try_again": "Try Again",
"go_back": "Go Back", "go_back": "Go Back",
"settings": "Settings", "settings": "Settings",
"any": "Any",
"close": "Close", "close": "Close",
"enable": "Enable", "enable": "Enable",
"disable": "Disable", "disable": "Disable",
@ -641,6 +642,9 @@
"chinese": "Chinese (Simplified)", "chinese": "Chinese (Simplified)",
"hindi": "Hindi", "hindi": "Hindi",
"serbian": "Serbian", "serbian": "Serbian",
"russian": "Russian",
"japanese": "Japanese",
"korean": "Korean",
"account": "Account", "account": "Account",
"content_discovery": "Content & Discovery", "content_discovery": "Content & Discovery",
"appearance": "Appearance", "appearance": "Appearance",
@ -1183,6 +1187,10 @@
"powered_by_introdb": "Powered by IntroDB", "powered_by_introdb": "Powered by IntroDB",
"autoplay_title": "Auto-play First Stream", "autoplay_title": "Auto-play First Stream",
"autoplay_desc": "Automatically start the first stream shown in the list.", "autoplay_desc": "Automatically start the first stream shown in the list.",
"preferred_quality_title": "Preferred Quality",
"preferred_quality_desc": "Select preferred quality for autoplay.",
"preferred_language_title": "Preferred Language",
"preferred_language_desc": "Select preferred language for autoplay.",
"resume_title": "Always Resume", "resume_title": "Always Resume",
"resume_desc": "Skip the resume prompt and automatically continue where you left off (if less than 85% watched).", "resume_desc": "Skip the resume prompt and automatically continue where you left off (if less than 85% watched).",
"engine_title": "Video Player Engine", "engine_title": "Video Player Engine",

View file

@ -301,7 +301,138 @@ const PlayerSettingsScreen: React.FC = () => {
</View> </View>
</View> </View>
{/* Preferred Quality for Autoplay */}
<View style={[styles.settingItem, styles.settingItemBorder, { borderTopColor: 'rgba(255,255,255,0.08)', borderTopWidth: 1 }]}>
<View style={styles.settingContent}>
<View style={[
styles.settingIconContainer,
{ backgroundColor: 'rgba(255,255,255,0.1)' }
]}>
<MaterialIcons
name="high-quality"
size={20}
color={currentTheme.colors.primary}
/>
</View>
<View style={styles.settingText}>
<Text
style={[
styles.settingTitle,
{ color: currentTheme.colors.text },
]}
>
{t('player.preferred_quality_title') || 'Preferred Quality'}
</Text>
<Text
style={[
styles.settingDescription,
{ color: currentTheme.colors.textMuted },
]}
>
{t('player.preferred_quality_desc') || 'Select preferred quality for autoplay'}
</Text>
</View>
</View>
<View style={styles.optionButtonsRow}>
{([
{ id: '4K', label: '4K' },
{ id: '1080p', label: '1080p' },
{ id: '720p', label: '720p' },
{ id: '480p', label: '480p' },
] as const).map((option) => (
<TouchableOpacity
key={option.id}
onPress={() => updateSetting('autoplayPreferredQuality', option.id)}
style={[
styles.optionButton,
settings.autoplayPreferredQuality === option.id && { backgroundColor: currentTheme.colors.primary },
]}
>
<Text
style={[
styles.optionButtonText,
{ color: settings.autoplayPreferredQuality === option.id ? '#fff' : currentTheme.colors.text },
]}
>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Preferred Language for Autoplay */}
<View style={[styles.settingItem, styles.settingItemBorder, { borderTopColor: 'rgba(255,255,255,0.08)', borderTopWidth: 1 }]}>
<View style={styles.settingContent}>
<View style={[
styles.settingIconContainer,
{ backgroundColor: 'rgba(255,255,255,0.1)' }
]}>
<MaterialIcons
name="language"
size={20}
color={currentTheme.colors.primary}
/>
</View>
<View style={styles.settingText}>
<Text
style={[
styles.settingTitle,
{ color: currentTheme.colors.text },
]}
>
{t('player.preferred_language_title') || 'Preferred Language'}
</Text>
<Text
style={[
styles.settingDescription,
{ color: currentTheme.colors.textMuted },
]}
>
{t('player.preferred_language_desc') || 'Select preferred language for autoplay'}
</Text>
</View>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.optionButtonsRowScroll}
>
{([
{ id: 'Any', label: t('common.any') || 'Any' },
{ id: 'English', label: t('settings.english') || 'English' },
{ id: 'Spanish', label: t('settings.spanish') || 'Spanish' },
{ id: 'French', label: t('settings.french') || 'French' },
{ id: 'German', label: t('settings.german') || 'German' },
{ id: 'Italian', label: t('settings.italian') || 'Italian' },
{ id: 'Portuguese', label: t('settings.portuguese') || 'Portuguese' },
{ id: 'Russian', label: t('settings.russian') || 'Russian' },
{ id: 'Hindi', label: t('settings.hindi') || 'Hindi' },
{ id: 'Chinese', label: t('settings.chinese') || 'Chinese' },
{ id: 'Japanese', label: t('settings.japanese') || 'Japanese' },
{ id: 'Korean', label: t('settings.korean') || 'Korean' },
] as const).map((option) => (
<TouchableOpacity
key={option.id}
onPress={() => updateSetting('autoplayPreferredLanguage', option.id)}
style={[
styles.optionButton,
styles.optionButtonLanguage,
settings.autoplayPreferredLanguage === option.id && { backgroundColor: currentTheme.colors.primary },
]}
>
<Text
style={[
styles.optionButtonText,
{ color: settings.autoplayPreferredLanguage === option.id ? '#fff' : currentTheme.colors.text },
]}
>
{option.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
{/* Video Player Engine for Android */} {/* Video Player Engine for Android */}
{Platform.OS === 'android' && !settings.useExternalPlayer && ( {Platform.OS === 'android' && !settings.useExternalPlayer && (
@ -653,6 +784,12 @@ const styles = StyleSheet.create({
paddingHorizontal: 52, paddingHorizontal: 52,
gap: 8, gap: 8,
}, },
optionButtonsRowScroll: {
paddingHorizontal: 52,
gap: 8,
marginTop: 12,
paddingBottom: 4,
},
optionButton: { optionButton: {
flex: 1, flex: 1,
paddingVertical: 10, paddingVertical: 10,
@ -662,6 +799,10 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
optionButtonLanguage: {
minWidth: 90,
flex: 0,
},
optionButtonWide: { optionButtonWide: {
flex: 1.5, flex: 1.5,
}, },

View file

@ -246,20 +246,67 @@ export const useStreamsScreen = () => {
if (allStreams.length === 0) return null; if (allStreams.length === 0) return null;
// Sort primarily by provider priority, then respect the addon's internal order (originalIndex) // Map preferred quality to numeric value
// This ensures if an addon lists 1080p before 4K, we pick 1080p const targetQuality = settings.autoplayPreferredQuality === '4K' ? 2160 : parseInt(settings.autoplayPreferredQuality, 10) || 1080;
allStreams.sort((a, b) => { const preferredLanguage = settings.autoplayPreferredLanguage;
// 1. Try to find streams matching preferred language
let languageMatchedStreams = allStreams;
if (preferredLanguage && preferredLanguage !== 'Any') {
languageMatchedStreams = allStreams.filter(item => {
const streamLang = (item.stream.lang || '').toLowerCase();
const prefLang = preferredLanguage.toLowerCase();
// Match by name if lang is not set, or match by lang property
return streamLang === prefLang ||
(item.stream.name || '').toLowerCase().includes(prefLang) ||
(item.stream.title || '').toLowerCase().includes(prefLang) ||
(item.stream.description || '').toLowerCase().includes(prefLang);
});
}
// 2. If no language match (and language wasn't 'Any'), just play the "first" stream
if (languageMatchedStreams.length === 0 && allStreams.length > 0) {
// Sort by provider priority and original index to find the "first" one
const sortedByPriority = [...allStreams].sort((a, b) => {
if (a.providerPriority !== b.providerPriority) return b.providerPriority - a.providerPriority;
return a.originalIndex - b.originalIndex;
});
logger.log(`🎯 Autoplay: No language match for ${preferredLanguage}, playing first available stream.`);
return sortedByPriority[0].stream;
}
if (languageMatchedStreams.length === 0) return null;
// 3. Among language-matched streams, find the one closest to target quality
// Sort primarily by how close the stream quality is to the preferred quality
// If quality is identical, sort by provider priority and then addon's internal order
languageMatchedStreams.sort((a, b) => {
// Calculate absolute difference from target quality
// Note: 0 quality (unknown/auto) is treated as being far from any specific target
const qualityA = a.quality === 0 ? 0 : a.quality;
const qualityB = b.quality === 0 ? 0 : b.quality;
const diffA = Math.abs(qualityA - targetQuality);
const diffB = Math.abs(qualityB - targetQuality);
if (diffA !== diffB) return diffA - diffB;
// Tie-break: if both are equally close (e.g. 720p and 1440p to 1080p)
// prefer the higher quality one
if (qualityA !== qualityB) return qualityB - qualityA;
if (a.providerPriority !== b.providerPriority) return b.providerPriority - a.providerPriority; if (a.providerPriority !== b.providerPriority) return b.providerPriority - a.providerPriority;
return a.originalIndex - b.originalIndex; return a.originalIndex - b.originalIndex;
}); });
const selected = languageMatchedStreams[0];
logger.log( logger.log(
`🎯 Best stream selected: ${allStreams[0].stream.name || allStreams[0].stream.title} (Quality: ${allStreams[0].quality}p)` `🎯 Best stream selected: ${selected.stream.name || selected.stream.title} (Lang: ${selected.stream.lang || 'unknown'}, Quality: ${selected.quality}p, Target: ${targetQuality}p)`
); );
return allStreams[0].stream; return selected.stream;
}, },
[filterByQuality, filterByLanguage] [filterByQuality, filterByLanguage, settings.autoplayPreferredQuality, settings.autoplayPreferredLanguage]
); );
// Current episode // Current episode