mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-19 16:31:44 +00:00
Added perefance for language and quality for autoplaying streams
This commit is contained in:
parent
5874a78ce0
commit
dee6bd3f52
11 changed files with 1230 additions and 1024 deletions
|
|
@ -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"/>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
1342
package-lock.json
generated
1342
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue