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-permission android:name="android.permission.INTERNET"/>
<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.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>

View file

@ -1,4 +1,4 @@
defaults.url=https://sentry.io/
defaults.org=tapframe
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
import android.graphics.Color
import com.brentvatne.common.toolbox.ReactBridgeUtils
import com.facebook.react.bridge.ReadableMap
@ -23,17 +22,6 @@ class SubtitleStyle public constructor() {
var subtitlesFollowVideo = true
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 {
private const val PROP_FONT_SIZE_TRACK = "fontSize"
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_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
fun parse(src: ReadableMap?): SubtitleStyle {
val subtitleStyle = SubtitleStyle()
@ -68,13 +41,6 @@ class SubtitleStyle public constructor() {
subtitleStyle.paddingRight = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_RIGHT, 0)
subtitleStyle.opacity = ReactBridgeUtils.safeGetFloat(src, PROP_OPACITY, 1f)
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
}
}

View file

@ -10,14 +10,11 @@ import android.widget.FrameLayout
import android.widget.TextView
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import com.brentvatne.common.api.ResizeMode
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
}
/**
* 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 {
// Add PlayerView with explicit layout parameters
val playerViewLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
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
val liveBadgeLayoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
liveBadgeLayoutParams.setMargins(16, 16, 16, 16)
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?) {
@ -126,10 +80,6 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
playerView.resizeMode = resizeMode
}
}
// Re-assert subtitle rendering mode for the current style.
updateSubtitleRenderingMode()
applySubtitleStyle(localStyle)
}
fun getPlayerView(): PlayerView = playerView
@ -158,63 +108,23 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
}
fun setSubtitleStyle(style: SubtitleStyle) {
localStyle = style
applySubtitleStyle(localStyle)
}
private fun applySubtitleStyle(style: SubtitleStyle) {
updateSubtitleRenderingMode()
playerView.subtitleView?.let { subtitleView ->
// Important:
// Avoid inheriting Android system caption settings via 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
// Reset to defaults
subtitleView.setUserDefaultStyle()
subtitleView.setUserDefaultTextSize()
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.
// Apply custom styling
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.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(
style.paddingLeft,
style.paddingTop,
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) {
subtitleView.alpha = style.opacity
subtitleView.visibility = android.view.View.VISIBLE
@ -222,59 +132,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
subtitleView.visibility = android.view.View.GONE
}
}
// 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
}
}
localStyle = style
}
fun setShutterColor(color: Int) {
@ -365,13 +223,6 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
}
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) {
playerView.post {
playerView.requestLayout()
@ -433,9 +284,6 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
// Re-apply bottomPaddingFraction once we have a concrete height.
updateSubtitleRenderingMode()
applySubtitleStyle(localStyle)
}
}
}

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",
"date-fns": "^4.1.0",
"eventemitter3": "^5.0.1",
"expo": "^54",
"expo": "^54.0.33",
"expo-application": "~7.0.7",
"expo-auth-session": "~7.0.8",
"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)
episodeLayoutStyle: 'vertical' | 'horizontal'; // Layout style for episode cards
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
scraperRepositoryUrl: string; // URL to the scraper repository
enableLocalScrapers: boolean; // Enable/disable local scraper functionality
@ -135,6 +137,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
tmdbLanguagePreference: 'en', // Default to English
episodeLayoutStyle: 'vertical', // Default to vertical layout for new installs
autoplayBestStream: false, // Disabled by default for user choice
autoplayPreferredQuality: '1080p', // Default to 1080p
autoplayPreferredLanguage: 'Any', // Default to Any language
// Local scraper defaults
scraperRepositoryUrl: '',
enableLocalScrapers: true,

View file

@ -14,6 +14,7 @@
"try_again": "Try Again",
"go_back": "Go Back",
"settings": "Settings",
"any": "Any",
"close": "Close",
"enable": "Enable",
"disable": "Disable",
@ -641,6 +642,9 @@
"chinese": "Chinese (Simplified)",
"hindi": "Hindi",
"serbian": "Serbian",
"russian": "Russian",
"japanese": "Japanese",
"korean": "Korean",
"account": "Account",
"content_discovery": "Content & Discovery",
"appearance": "Appearance",
@ -1183,6 +1187,10 @@
"powered_by_introdb": "Powered by IntroDB",
"autoplay_title": "Auto-play First Stream",
"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_desc": "Skip the resume prompt and automatically continue where you left off (if less than 85% watched).",
"engine_title": "Video Player Engine",

View file

@ -291,19 +291,150 @@ const PlayerSettingsScreen: React.FC = () => {
{t('player.autoplay_desc')}
</Text>
</View>
<Switch
value={settings.autoplayBestStream}
onValueChange={(value) => updateSetting('autoplayBestStream', value)}
trackColor={{ false: '#767577', true: currentTheme.colors.primary }}
thumbColor={settings.autoplayBestStream ? '#ffffff' : '#f4f3f4'}
ios_backgroundColor="#3e3e3e"
/>
</View>
</View>
<Switch
value={settings.autoplayBestStream}
onValueChange={(value) => updateSetting('autoplayBestStream', value)}
trackColor={{ false: '#767577', true: currentTheme.colors.primary }}
thumbColor={settings.autoplayBestStream ? '#ffffff' : '#f4f3f4'}
ios_backgroundColor="#3e3e3e"
/>
</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>
{/* Video Player Engine for Android */}
{/* 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 */}
{Platform.OS === 'android' && !settings.useExternalPlayer && (
<>
<View style={[styles.settingItem, styles.settingItemBorder, { borderTopColor: 'rgba(255,255,255,0.08)', borderTopWidth: 1 }]}>
@ -653,6 +784,12 @@ const styles = StyleSheet.create({
paddingHorizontal: 52,
gap: 8,
},
optionButtonsRowScroll: {
paddingHorizontal: 52,
gap: 8,
marginTop: 12,
paddingBottom: 4,
},
optionButton: {
flex: 1,
paddingVertical: 10,
@ -662,6 +799,10 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
optionButtonLanguage: {
minWidth: 90,
flex: 0,
},
optionButtonWide: {
flex: 1.5,
},

View file

@ -246,20 +246,67 @@ export const useStreamsScreen = () => {
if (allStreams.length === 0) return null;
// Sort primarily by provider priority, then respect the addon's internal order (originalIndex)
// This ensures if an addon lists 1080p before 4K, we pick 1080p
allStreams.sort((a, b) => {
// Map preferred quality to numeric value
const targetQuality = settings.autoplayPreferredQuality === '4K' ? 2160 : parseInt(settings.autoplayPreferredQuality, 10) || 1080;
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;
return a.originalIndex - b.originalIndex;
});
const selected = languageMatchedStreams[0];
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