This commit is contained in:
meilluer 2026-02-21 03:05:14 +08:00 committed by GitHub
commit fe755981fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1332 additions and 1079 deletions

47
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,47 @@
name: Build Android APK
on:
schedule:
workflow_dispatch: # Allows you to run this manually from the "Actions" tab
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Install Dependencies
run: npm install --legacy-peer-deps
- name: Setup Expo
uses: expo/expo-github-action@v8
with:
expo-version: latest
- name: Prebuild Android
run: npx expo prebuild --platform android
- name: Build APK (Release)
run: |
cd android
chmod +x gradlew
./gradlew assembleRelease
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: nuvio-app-release
path: android/app/build/outputs/apk/release/app-release.apk

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. subtitleView.setUserDefaultTextSize()
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()) { // Apply custom styling
"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) { 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_SP, style.fontSize.toFloat())
// 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()
} }
// 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
@ -137,6 +139,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

@ -641,18 +641,6 @@
"chinese": "Chinese (Simplified)", "chinese": "Chinese (Simplified)",
"hindi": "Hindi", "hindi": "Hindi",
"serbian": "Serbian", "serbian": "Serbian",
"hebrew": "Hebrew",
"bulgarian": "Bulgarian",
"polish": "Polish",
"czech": "Czech",
"turkish": "Turkish",
"slovenian": "Slovenian",
"macedonian": "Macedonian",
"russian": "Russian",
"filipino": "Filipino",
"dutch_nl": "Dutch (Netherlands)",
"romanian": "Romanian",
"albanian": "Albanian",
"account": "Account", "account": "Account",
"content_discovery": "Content & Discovery", "content_discovery": "Content & Discovery",
"appearance": "Appearance", "appearance": "Appearance",
@ -774,13 +762,13 @@
"analytics_enabled_title": "Analytics Enabled", "analytics_enabled_title": "Analytics Enabled",
"analytics_enabled_message": "Usage data will be collected to help improve the app. You can disable this at any time.", "analytics_enabled_message": "Usage data will be collected to help improve the app. You can disable this at any time.",
"disable_error_reporting_title": "Disable Error Reporting?", "disable_error_reporting_title": "Disable Error Reporting?",
"disable_error_reporting_message": "Disabling error reporting means we wont be notified of crashes or issues you experience. This may affect our ability to fix bugs.", "disable_error_reporting_message": "Disabling error reporting means we won\u2019t be notified of crashes or issues you experience. This may affect our ability to fix bugs.",
"enable_session_replay_title": "Enable Session Replay?", "enable_session_replay_title": "Enable Session Replay?",
"enable_session_replay_message": "Session replay records your screen when errors occur to help us understand what happened. This may capture visible content on your screen.", "enable_session_replay_message": "Session replay records your screen when errors occur to help us understand what happened. This may capture visible content on your screen.",
"enable_pii_title": "Enable PII Collection?", "enable_pii_title": "Enable PII Collection?",
"enable_pii_message": "This allows collection of personally identifiable information like IP address and device details. This data helps diagnose issues but increases privacy exposure.", "enable_pii_message": "This allows collection of personally identifiable information like IP address and device details. This data helps diagnose issues but increases privacy exposure.",
"disable_all_title": "Disable All Telemetry?", "disable_all_title": "Disable All Telemetry?",
"disable_all_message": "This will disable all analytics, error reporting, and session replay. We wont receive any data about app usage or crashes.", "disable_all_message": "This will disable all analytics, error reporting, and session replay. We won\u2019t receive any data about app usage or crashes.",
"disable_all_button": "Disable All", "disable_all_button": "Disable All",
"all_disabled_title": "All Telemetry Disabled", "all_disabled_title": "All Telemetry Disabled",
"all_disabled_message": "All data collection has been disabled. Changes take effect on next app restart.", "all_disabled_message": "All data collection has been disabled. Changes take effect on next app restart.",
@ -913,8 +901,8 @@
}, },
"debrid": { "debrid": {
"title": "Debrid Integration", "title": "Debrid Integration",
"description_torbox": "Connect Torbox to use your account-based source preferences. Enter your API key below to configure the integration.", "description_torbox": "Unlock 4K high-quality streams and lightning-fast speeds by integrating Torbox. Enter your API Key below to instantly upgrade your streaming experience.",
"description_torrentio": "Configure Torrentio as an external source integration. A compatible debrid account may be required depending on your setup.", "description_torrentio": "Configure Torrentio to get torrent streams for movies and TV shows. A debrid service is required to stream content.",
"tab_torbox": "TorBox", "tab_torbox": "TorBox",
"tab_torrentio": "Torrentio", "tab_torrentio": "Torrentio",
"status_connected": "Connected", "status_connected": "Connected",
@ -941,15 +929,15 @@
"enter_api_key": "Enter your API Key", "enter_api_key": "Enter your API Key",
"connect_button": "Connect & Install", "connect_button": "Connect & Install",
"connecting": "Connecting...", "connecting": "Connecting...",
"unlock_speeds_title": "Optional Torbox Subscription", "unlock_speeds_title": "Unlock Premium Speeds",
"unlock_speeds_desc": "Torbox offers account tiers with enhanced performance and availability features.", "unlock_speeds_desc": "Get a Torbox subscription to access cached high-quality streams with zero buffering.",
"get_subscription": "Get Subscription", "get_subscription": "Get Subscription",
"powered_by": "Powered by", "powered_by": "Powered by",
"disclaimer_torbox": "Nuvio is not affiliated with Torbox in any way.", "disclaimer_torbox": "Nuvio is not affiliated with Torbox in any way.",
"disclaimer_torrentio": "Nuvio is not affiliated with Torrentio in any way.", "disclaimer_torrentio": "Nuvio is not affiliated with Torrentio in any way.",
"installed_badge": "✓ INSTALLED", "installed_badge": "✓ INSTALLED",
"promo_title": "⚡ Need a Debrid Service?", "promo_title": "⚡ Need a Debrid Service?",
"promo_desc": "Use TorBox if you want account-managed performance features for supported integrations.", "promo_desc": "Get TorBox for lightning-fast 4K streaming with zero buffering. Premium cached torrents and instant downloads.",
"promo_button": "Get TorBox Subscription", "promo_button": "Get TorBox Subscription",
"service_label": "Debrid Service *", "service_label": "Debrid Service *",
"api_key_label": "API Key *", "api_key_label": "API Key *",
@ -1343,7 +1331,7 @@
"user_resp_title": "User Responsibility", "user_resp_title": "User Responsibility",
"user_resp_text": "Users are solely responsible for the plugins they install and the content they access. By using this application, you agree to ensure that you have the legal right to access any content you view using Nuvio. The developers of Nuvio do not endorse or encourage copyright infringement.", "user_resp_text": "Users are solely responsible for the plugins they install and the content they access. By using this application, you agree to ensure that you have the legal right to access any content you view using Nuvio. The developers of Nuvio do not endorse or encourage copyright infringement.",
"dmca_title": "Copyright & DMCA", "dmca_title": "Copyright & DMCA",
"dmca_text": "We respect the intellectual property rights of others. Nuvio does not host media content. If you believe this project's code, assets, or interface infringes your rights, submit a notice through the official project contact channels listed on the website and repository.", "dmca_text": "We respect the intellectual property rights of others. Since Nuvio does not host any content, we cannot remove content from the internet. However, if you believe that the application interface itself infringes on your rights, please contact us.",
"warranty_title": "No Warranty", "warranty_title": "No Warranty",
"warranty_text": "This software is provided \"as is\", without warranty of any kind, express or implied. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability arising from the use of this software." "warranty_text": "This software is provided \"as is\", without warranty of any kind, express or implied. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability arising from the use of this software."
}, },

View file

@ -261,48 +261,6 @@ const PlayerSettingsScreen: React.FC = () => {
}, },
]} ]}
> >
<View style={styles.settingItem}>
<View style={styles.settingContent}>
<View style={[
styles.settingIconContainer,
{ backgroundColor: 'rgba(255,255,255,0.1)' }
]}>
<MaterialIcons
name="play-arrow"
size={20}
color={currentTheme.colors.primary}
/>
</View>
<View style={styles.settingText}>
<Text
style={[
styles.settingTitle,
{ color: currentTheme.colors.text },
]}
>
{t('player.autoplay_title')}
</Text>
<Text
style={[
styles.settingDescription,
{ color: currentTheme.colors.textMuted },
]}
>
{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>
{/* Video Player Engine for Android */} {/* Video Player Engine for Android */}
{Platform.OS === 'android' && !settings.useExternalPlayer && ( {Platform.OS === 'android' && !settings.useExternalPlayer && (
<> <>
@ -653,6 +611,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 +626,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

@ -70,6 +70,23 @@ const SUBTITLE_SOURCE_OPTIONS = [
{ value: 'any', label: 'Any Available', description: 'Use first available subtitle track' }, { value: 'any', label: 'Any Available', description: 'Use first available subtitle track' },
]; ];
/**
* Quality options for the "Auto-play First Stream" feature.
* These are used in the bottom sheet selection to allow users to target specific resolutions.
*/
const AUTOPLAY_QUALITY_OPTIONS = [
{ id: '4320p', label: '8K' },
{ id: '4K', label: '4K' },
{ id: '3660p', label: '3660p' },
{ id: '1440p', label: '1440p' },
{ id: '1080p', label: '1080p' },
{ id: '720p', label: '720p' },
{ id: '480p', label: '480p' },
{ id: '360p', label: '360p' },
{ id: '240p', label: '240p' },
{ id: '140p', label: '140p' },
];
// Props for the reusable content component // Props for the reusable content component
interface PlaybackSettingsContentProps { interface PlaybackSettingsContentProps {
isTablet?: boolean; isTablet?: boolean;
@ -159,30 +176,55 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
const audioLanguageSheetRef = useRef<BottomSheetModal>(null); const audioLanguageSheetRef = useRef<BottomSheetModal>(null);
const subtitleLanguageSheetRef = useRef<BottomSheetModal>(null); const subtitleLanguageSheetRef = useRef<BottomSheetModal>(null);
const subtitleSourceSheetRef = useRef<BottomSheetModal>(null); const subtitleSourceSheetRef = useRef<BottomSheetModal>(null);
const autoplayQualitySheetRef = useRef<BottomSheetModal>(null);
const autoplayLanguageSheetRef = useRef<BottomSheetModal>(null);
// Snap points // Snap points
const languageSnapPoints = useMemo(() => ['70%'], []); const languageSnapPoints = useMemo(() => ['70%'], []);
const sourceSnapPoints = useMemo(() => ['45%'], []); const sourceSnapPoints = useMemo(() => ['45%'], []);
const qualitySnapPoints = useMemo(() => ['45%'], []);
// Handlers to present sheets - ensure only one is open at a time // Handlers to present sheets - ensure only one is open at a time
const openAudioLanguageSheet = useCallback(() => { const openAudioLanguageSheet = useCallback(() => {
subtitleLanguageSheetRef.current?.dismiss(); subtitleLanguageSheetRef.current?.dismiss();
subtitleSourceSheetRef.current?.dismiss(); subtitleSourceSheetRef.current?.dismiss();
autoplayQualitySheetRef.current?.dismiss();
autoplayLanguageSheetRef.current?.dismiss();
setTimeout(() => audioLanguageSheetRef.current?.present(), 100); setTimeout(() => audioLanguageSheetRef.current?.present(), 100);
}, []); }, []);
const openSubtitleLanguageSheet = useCallback(() => { const openSubtitleLanguageSheet = useCallback(() => {
audioLanguageSheetRef.current?.dismiss(); audioLanguageSheetRef.current?.dismiss();
subtitleSourceSheetRef.current?.dismiss(); subtitleSourceSheetRef.current?.dismiss();
autoplayQualitySheetRef.current?.dismiss();
autoplayLanguageSheetRef.current?.dismiss();
setTimeout(() => subtitleLanguageSheetRef.current?.present(), 100); setTimeout(() => subtitleLanguageSheetRef.current?.present(), 100);
}, []); }, []);
const openSubtitleSourceSheet = useCallback(() => { const openSubtitleSourceSheet = useCallback(() => {
audioLanguageSheetRef.current?.dismiss(); audioLanguageSheetRef.current?.dismiss();
subtitleLanguageSheetRef.current?.dismiss(); subtitleLanguageSheetRef.current?.dismiss();
autoplayQualitySheetRef.current?.dismiss();
autoplayLanguageSheetRef.current?.dismiss();
setTimeout(() => subtitleSourceSheetRef.current?.present(), 100); setTimeout(() => subtitleSourceSheetRef.current?.present(), 100);
}, []); }, []);
const openAutoplayQualitySheet = useCallback(() => {
audioLanguageSheetRef.current?.dismiss();
subtitleLanguageSheetRef.current?.dismiss();
subtitleSourceSheetRef.current?.dismiss();
autoplayLanguageSheetRef.current?.dismiss();
setTimeout(() => autoplayQualitySheetRef.current?.present(), 100);
}, []);
const openAutoplayLanguageSheet = useCallback(() => {
audioLanguageSheetRef.current?.dismiss();
subtitleLanguageSheetRef.current?.dismiss();
subtitleSourceSheetRef.current?.dismiss();
autoplayQualitySheetRef.current?.dismiss();
setTimeout(() => autoplayLanguageSheetRef.current?.present(), 100);
}, []);
const isItemVisible = (itemId: string) => { const isItemVisible = (itemId: string) => {
if (!config?.items) return true; if (!config?.items) return true;
const item = config.items[itemId]; const item = config.items[itemId];
@ -234,6 +276,16 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
subtitleSourceSheetRef.current?.dismiss(); subtitleSourceSheetRef.current?.dismiss();
}; };
const handleSelectAutoplayQuality = (quality: string) => {
updateSetting('autoplayPreferredQuality', quality);
autoplayQualitySheetRef.current?.dismiss();
};
const handleSelectAutoplayLanguage = (language: string) => {
updateSetting('autoplayPreferredLanguage', language);
autoplayLanguageSheetRef.current?.dismiss();
};
return ( return (
<> <>
{hasVisibleItems(['video_player']) && ( {hasVisibleItems(['video_player']) && (
@ -256,6 +308,38 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
)} )}
<SettingsCard title={t('player.section_playback', { defaultValue: 'Playback' })} isTablet={isTablet}> <SettingsCard title={t('player.section_playback', { defaultValue: 'Playback' })} isTablet={isTablet}>
<SettingItem
title={t('player.autoplay_title', { defaultValue: 'Auto-play First Stream' })}
description={t('player.autoplay_desc', { defaultValue: 'Automatically start the first stream shown in the list.' })}
icon="play-arrow"
renderControl={() => (
<CustomSwitch
value={settings?.autoplayBestStream ?? false}
onValueChange={(value) => updateSetting('autoplayBestStream', value)}
/>
)}
isTablet={isTablet}
/>
{settings?.autoplayBestStream && (
<>
<SettingItem
title={t('player.preferred_quality_title', { defaultValue: 'Preferred Quality' })}
description={settings?.autoplayPreferredQuality || '1080p'}
icon="high-quality"
renderControl={() => <ChevronRight />}
onPress={openAutoplayQualitySheet}
isTablet={isTablet}
/>
<SettingItem
title={t('player.preferred_language_title', { defaultValue: 'Preferred Language' })}
description={settings?.autoplayPreferredLanguage === 'Any' ? t('common.any') : settings?.autoplayPreferredLanguage || t('common.any')}
icon="language"
renderControl={() => <ChevronRight />}
onPress={openAutoplayLanguageSheet}
isTablet={isTablet}
/>
</>
)}
<SettingItem <SettingItem
title={t('player.skip_intro_settings_title', { defaultValue: 'Skip Intro' })} title={t('player.skip_intro_settings_title', { defaultValue: 'Skip Intro' })}
description={t('player.powered_by_introdb', { defaultValue: 'Powered by IntroDB' })} description={t('player.powered_by_introdb', { defaultValue: 'Powered by IntroDB' })}
@ -536,6 +620,95 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
})} })}
</BottomSheetScrollView> </BottomSheetScrollView>
</BottomSheetModal> </BottomSheetModal>
{/* Autoplay Quality Bottom Sheet */}
<BottomSheetModal
ref={autoplayQualitySheetRef}
index={0}
snapPoints={qualitySnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={{ backgroundColor: '#1a1a1a' }}
handleIndicatorStyle={{ backgroundColor: 'rgba(255,255,255,0.3)' }}
>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('player.preferred_quality_title')}</Text>
</View>
<BottomSheetScrollView contentContainerStyle={styles.sheetContent}>
{AUTOPLAY_QUALITY_OPTIONS.map((option) => {
const isSelected = option.id === (settings?.autoplayPreferredQuality || '1080p');
return (
<TouchableOpacity
key={option.id}
style={[
styles.languageItem,
isSelected && { backgroundColor: currentTheme.colors.primary + '20' }
]}
onPress={() => handleSelectAutoplayQuality(option.id)}
>
<Text style={[styles.languageName, { color: isSelected ? currentTheme.colors.primary : '#fff' }]}>
{option.label}
</Text>
{isSelected && (
<MaterialIcons name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
);
})}
</BottomSheetScrollView>
</BottomSheetModal>
{/* Autoplay Language Bottom Sheet */}
<BottomSheetModal
ref={autoplayLanguageSheetRef}
index={0}
snapPoints={languageSnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={{ backgroundColor: '#1a1a1a' }}
handleIndicatorStyle={{ backgroundColor: 'rgba(255,255,255,0.3)' }}
>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('player.preferred_language_title')}</Text>
</View>
<BottomSheetScrollView contentContainerStyle={styles.sheetContent}>
{[
{ 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' },
].map((option) => {
const isSelected = option.id === (settings?.autoplayPreferredLanguage || 'Any');
return (
<TouchableOpacity
key={option.id}
style={[
styles.languageItem,
isSelected && { backgroundColor: currentTheme.colors.primary + '20' }
]}
onPress={() => handleSelectAutoplayLanguage(option.id)}
>
<Text style={[styles.languageName, { color: isSelected ? currentTheme.colors.primary : '#fff' }]}>
{option.label}
</Text>
{isSelected && (
<MaterialIcons name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
);
})}
</BottomSheetScrollView>
</BottomSheetModal>
</> </>
); );
}; };

View file

@ -22,6 +22,7 @@ import { TABLET_BREAKPOINT } from './constants';
import { import {
filterStreamsByQuality, filterStreamsByQuality,
filterStreamsByLanguage, filterStreamsByLanguage,
getLanguageVariations,
getQualityNumeric, getQualityNumeric,
inferVideoTypeFromUrl, inferVideoTypeFromUrl,
sortStreamsByQuality, sortStreamsByQuality,
@ -246,20 +247,77 @@ 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
// Uses a robust set of variations (e.g. 'spa' for Spanish) to match against
// various stream metadata fields (lang, title, description).
let languageMatchedStreams = allStreams;
if (preferredLanguage && preferredLanguage !== 'Any') {
languageMatchedStreams = allStreams.filter(item => {
const streamName = (item.stream.name || '').toLowerCase();
const streamTitle = (item.stream.title || '').toLowerCase();
const streamDesc = (item.stream.description || '').toLowerCase();
const streamLang = (item.stream.lang || '').toLowerCase();
const variations = getLanguageVariations(preferredLanguage);
return variations.some(variant => {
const variantLower = variant.toLowerCase();
return streamLang === variantLower ||
streamName.includes(variantLower) ||
streamTitle.includes(variantLower) ||
streamDesc.includes(variantLower);
});
});
}
// 2. Fallback: If no language match (and language wasn't 'Any'), just play the "first" prioritized stream.
// Priority is determined by provider order then stream's original position.
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

View file

@ -89,27 +89,36 @@ export const filterStreamsByLanguage = (
/** /**
* Extract numeric quality from stream title * Extract numeric quality from stream title
* Maps common quality labels (4K, 8K) to their vertical resolution (2160p, 4320p)
* and extracts numeric values from patterns like "1080p".
*/ */
export const getQualityNumeric = (title: string | undefined): number => { export const getQualityNumeric = (title: string | undefined): number => {
if (!title) return 0; if (!title) return 0;
// Check for 4K first (treat as 2160p) // Check for 4K first (treat as 2160p)
if (/\b4k\b/i.test(title)) { if (/\b(4k|uhd)\b/i.test(title)) {
return 2160; return 2160;
} }
// Check for 8K (treat as 4320p)
if (/\b8k\b/i.test(title)) {
return 4320;
}
// General pattern for numbers followed by 'p' (e.g., 1080p, 3660p)
const matchWithP = title.match(/(\d+)p/i); const matchWithP = title.match(/(\d+)p/i);
if (matchWithP) return parseInt(matchWithP[1], 10); if (matchWithP) return parseInt(matchWithP[1], 10);
const qualityPatterns = [/\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i]; // Standalone common resolutions if 'p' suffix is missing (e.g., "UHD 2160")
const commonResolutions = [/\b(140|240|360|480|720|1080|1440|2160|3660|4320|8000)\b/];
for (const pattern of qualityPatterns) { for (const pattern of commonResolutions) {
const match = title.match(pattern); const match = title.match(pattern);
if (match) { if (match) {
const quality = parseInt(match[1], 10); const quality = parseInt(match[1], 10);
if (quality >= 240 && quality <= 8000) return quality; if (quality >= 140 && quality <= 8000) return quality;
} }
} }
return 0; return 0;
}; };