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)
} }
} }
} }

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