mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-19 08:22:00 +00:00
Merge 93e74af942 into a05a16f67b
This commit is contained in:
commit
fe755981fb
14 changed files with 1332 additions and 1079 deletions
47
.github/workflows/build.yml
vendored
Normal file
47
.github/workflows/build.yml
vendored
Normal 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
|
||||
|
|
@ -2,12 +2,12 @@
|
|||
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
defaults.url=https://sentry.io/
|
||||
defaults.org=tapframe
|
||||
defaults.project=react-native
|
||||
auth.token=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
|
||||
# Using SENTRY_AUTH_TOKEN environment variable
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package com.brentvatne.common.api
|
||||
|
||||
import android.graphics.Color
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
|
||||
|
|
@ -23,17 +22,6 @@ class SubtitleStyle public constructor() {
|
|||
var subtitlesFollowVideo = true
|
||||
private set
|
||||
|
||||
// Extended styling (used by ExoPlayerView via Media3 SubtitleView)
|
||||
// Stored as Android color ints to avoid parsing multiple times.
|
||||
var textColor: Int? = null
|
||||
private set
|
||||
var backgroundColor: Int? = null
|
||||
private set
|
||||
var edgeType: String? = null
|
||||
private set
|
||||
var edgeColor: Int? = null
|
||||
private set
|
||||
|
||||
companion object {
|
||||
private const val PROP_FONT_SIZE_TRACK = "fontSize"
|
||||
private const val PROP_PADDING_BOTTOM = "paddingBottom"
|
||||
|
|
@ -43,21 +31,6 @@ class SubtitleStyle public constructor() {
|
|||
private const val PROP_OPACITY = "opacity"
|
||||
private const val PROP_SUBTITLES_FOLLOW_VIDEO = "subtitlesFollowVideo"
|
||||
|
||||
// Extended props (optional)
|
||||
private const val PROP_TEXT_COLOR = "textColor"
|
||||
private const val PROP_BACKGROUND_COLOR = "backgroundColor"
|
||||
private const val PROP_EDGE_TYPE = "edgeType"
|
||||
private const val PROP_EDGE_COLOR = "edgeColor"
|
||||
|
||||
private fun parseColorOrNull(value: String?): Int? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
return try {
|
||||
Color.parseColor(value)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun parse(src: ReadableMap?): SubtitleStyle {
|
||||
val subtitleStyle = SubtitleStyle()
|
||||
|
|
@ -68,13 +41,6 @@ class SubtitleStyle public constructor() {
|
|||
subtitleStyle.paddingRight = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_RIGHT, 0)
|
||||
subtitleStyle.opacity = ReactBridgeUtils.safeGetFloat(src, PROP_OPACITY, 1f)
|
||||
subtitleStyle.subtitlesFollowVideo = ReactBridgeUtils.safeGetBool(src, PROP_SUBTITLES_FOLLOW_VIDEO, true)
|
||||
|
||||
// Extended styling
|
||||
subtitleStyle.textColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_TEXT_COLOR, null))
|
||||
subtitleStyle.backgroundColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_BACKGROUND_COLOR, null))
|
||||
subtitleStyle.edgeType = ReactBridgeUtils.safeGetString(src, PROP_EDGE_TYPE, null)
|
||||
subtitleStyle.edgeColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_EDGE_COLOR, null))
|
||||
|
||||
return subtitleStyle
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,14 +10,11 @@ import android.widget.FrameLayout
|
|||
import android.widget.TextView
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.text.CueGroup
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.CaptionStyleCompat
|
||||
import androidx.media3.ui.DefaultTimeBar
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import com.brentvatne.common.api.ResizeMode
|
||||
import com.brentvatne.common.api.SubtitleStyle
|
||||
|
||||
|
|
@ -55,58 +52,15 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtitles rendered in a full-size overlay (NOT inside PlayerView's content frame).
|
||||
* This keeps subtitles anchored in-place even when the video surface/content frame moves
|
||||
* due to aspect ratio / resizeMode changes.
|
||||
*
|
||||
* Controlled by SubtitleStyle.subtitlesFollowVideo.
|
||||
*/
|
||||
private val overlaySubtitleView = SubtitleView(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
visibility = View.GONE
|
||||
// We control styling via SubtitleStyle; don't pull Android system caption defaults.
|
||||
setApplyEmbeddedStyles(true)
|
||||
setApplyEmbeddedFontSizes(true)
|
||||
}
|
||||
|
||||
private fun updateSubtitleRenderingMode() {
|
||||
val internalSubtitleView = playerView.subtitleView
|
||||
val followVideo = localStyle.subtitlesFollowVideo
|
||||
val shouldShow = localStyle.opacity != 0.0f
|
||||
|
||||
if (followVideo) {
|
||||
internalSubtitleView?.visibility = if (shouldShow) View.VISIBLE else View.GONE
|
||||
overlaySubtitleView.visibility = View.GONE
|
||||
} else {
|
||||
// Hard-disable PlayerView's internal subtitle view. PlayerView can recreate/toggle this view
|
||||
// during resize/layout, so we re-assert this in multiple lifecycle points.
|
||||
internalSubtitleView?.visibility = View.GONE
|
||||
internalSubtitleView?.alpha = 0f
|
||||
overlaySubtitleView.visibility = if (shouldShow) View.VISIBLE else View.GONE
|
||||
overlaySubtitleView.alpha = 1f
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Add PlayerView with explicit layout parameters
|
||||
val playerViewLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
addView(playerView, playerViewLayoutParams)
|
||||
|
||||
// Add overlay subtitles above PlayerView (so it doesn't move with video content frame)
|
||||
val subtitleOverlayLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
addView(overlaySubtitleView, subtitleOverlayLayoutParams)
|
||||
|
||||
// Add live badge with its own layout parameters
|
||||
val liveBadgeLayoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
||||
liveBadgeLayoutParams.setMargins(16, 16, 16, 16)
|
||||
addView(liveBadge, liveBadgeLayoutParams)
|
||||
|
||||
// PlayerView may internally recreate its subtitle view during relayouts (e.g. resizeMode changes).
|
||||
// Ensure our rendering mode is re-applied whenever PlayerView lays out.
|
||||
playerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
updateSubtitleRenderingMode()
|
||||
}
|
||||
}
|
||||
|
||||
fun setPlayer(player: ExoPlayer?) {
|
||||
|
|
@ -126,10 +80,6 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
}
|
||||
|
||||
// Re-assert subtitle rendering mode for the current style.
|
||||
updateSubtitleRenderingMode()
|
||||
applySubtitleStyle(localStyle)
|
||||
}
|
||||
|
||||
fun getPlayerView(): PlayerView = playerView
|
||||
|
|
@ -158,63 +108,23 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
}
|
||||
|
||||
fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
localStyle = style
|
||||
applySubtitleStyle(localStyle)
|
||||
}
|
||||
|
||||
private fun applySubtitleStyle(style: SubtitleStyle) {
|
||||
updateSubtitleRenderingMode()
|
||||
|
||||
playerView.subtitleView?.let { subtitleView ->
|
||||
// Important:
|
||||
// Avoid inheriting Android system caption settings via setUserDefaultStyle(),
|
||||
// because those can force a background/window that the app doesn't want.
|
||||
val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor
|
||||
val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT
|
||||
val resolvedEdgeColor = style.edgeColor ?: Color.BLACK
|
||||
// Reset to defaults
|
||||
subtitleView.setUserDefaultStyle()
|
||||
subtitleView.setUserDefaultTextSize()
|
||||
|
||||
val resolvedEdgeType = when (style.edgeType?.lowercase()) {
|
||||
"outline" -> CaptionStyleCompat.EDGE_TYPE_OUTLINE
|
||||
"shadow" -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
|
||||
else -> CaptionStyleCompat.EDGE_TYPE_NONE
|
||||
}
|
||||
|
||||
// windowColor MUST be transparent to avoid the "caption window" background.
|
||||
val captionStyle = CaptionStyleCompat(
|
||||
resolvedTextColor,
|
||||
resolvedBackgroundColor,
|
||||
Color.TRANSPARENT,
|
||||
resolvedEdgeType,
|
||||
resolvedEdgeColor,
|
||||
null
|
||||
)
|
||||
subtitleView.setStyle(captionStyle)
|
||||
|
||||
// Text size: if not provided, fall back to user default size.
|
||||
// Apply custom styling
|
||||
if (style.fontSize > 0) {
|
||||
// Use DIP so the value matches React Native's dp-based fontSize more closely.
|
||||
// SP would multiply by system fontScale and makes "30" look larger than RN "30".
|
||||
subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
|
||||
} else {
|
||||
subtitleView.setUserDefaultTextSize()
|
||||
subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, style.fontSize.toFloat())
|
||||
}
|
||||
|
||||
// Horizontal padding is still useful (safe area); vertical offset is handled via bottomPaddingFraction.
|
||||
subtitleView.setPadding(
|
||||
style.paddingLeft,
|
||||
style.paddingTop,
|
||||
style.paddingRight,
|
||||
0
|
||||
style.paddingBottom
|
||||
)
|
||||
|
||||
// Bottom offset for *internal* subtitles:
|
||||
// Use Media3 SubtitleView's bottomPaddingFraction (moves cues up) rather than raw view padding.
|
||||
if (style.paddingBottom > 0 && playerView.height > 0) {
|
||||
val fraction = (style.paddingBottom.toFloat() / playerView.height.toFloat())
|
||||
.coerceIn(0f, 0.9f)
|
||||
subtitleView.setBottomPaddingFraction(fraction)
|
||||
}
|
||||
|
||||
if (style.opacity != 0.0f) {
|
||||
subtitleView.alpha = style.opacity
|
||||
subtitleView.visibility = android.view.View.VISIBLE
|
||||
|
|
@ -222,59 +132,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
subtitleView.visibility = android.view.View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the same styling to the overlay subtitle view.
|
||||
run {
|
||||
val subtitleView = overlaySubtitleView
|
||||
|
||||
val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor
|
||||
val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT
|
||||
val resolvedEdgeColor = style.edgeColor ?: Color.BLACK
|
||||
|
||||
val resolvedEdgeType = when (style.edgeType?.lowercase()) {
|
||||
"outline" -> CaptionStyleCompat.EDGE_TYPE_OUTLINE
|
||||
"shadow" -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
|
||||
else -> CaptionStyleCompat.EDGE_TYPE_NONE
|
||||
}
|
||||
|
||||
val captionStyle = CaptionStyleCompat(
|
||||
resolvedTextColor,
|
||||
resolvedBackgroundColor,
|
||||
Color.TRANSPARENT,
|
||||
resolvedEdgeType,
|
||||
resolvedEdgeColor,
|
||||
null
|
||||
)
|
||||
subtitleView.setStyle(captionStyle)
|
||||
|
||||
if (style.fontSize > 0) {
|
||||
// Use DIP so the value matches React Native's dp-based fontSize more closely.
|
||||
subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
|
||||
} else {
|
||||
subtitleView.setUserDefaultTextSize()
|
||||
}
|
||||
|
||||
subtitleView.setPadding(
|
||||
style.paddingLeft,
|
||||
style.paddingTop,
|
||||
style.paddingRight,
|
||||
0
|
||||
)
|
||||
|
||||
// Bottom offset relative to the full view height (stable even when video content frame moves).
|
||||
val h = height.takeIf { it > 0 } ?: subtitleView.height
|
||||
if (style.paddingBottom > 0 && h > 0) {
|
||||
val fraction = (style.paddingBottom.toFloat() / h.toFloat())
|
||||
.coerceIn(0f, 0.9f)
|
||||
subtitleView.setBottomPaddingFraction(fraction)
|
||||
} else {
|
||||
subtitleView.setBottomPaddingFraction(0f)
|
||||
}
|
||||
|
||||
if (style.opacity != 0.0f) {
|
||||
subtitleView.alpha = style.opacity
|
||||
}
|
||||
}
|
||||
localStyle = style
|
||||
}
|
||||
|
||||
fun setShutterColor(color: Int) {
|
||||
|
|
@ -365,13 +223,6 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
}
|
||||
|
||||
private val playerListener = object : Player.Listener {
|
||||
override fun onCues(cueGroup: CueGroup) {
|
||||
// Keep overlay subtitles in sync. This does NOT interfere with PlayerView's own subtitle rendering.
|
||||
// When subtitlesFollowVideo=false, overlaySubtitleView is the visible one.
|
||||
updateSubtitleRenderingMode()
|
||||
overlaySubtitleView.setCues(cueGroup.cues)
|
||||
}
|
||||
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
playerView.post {
|
||||
playerView.requestLayout()
|
||||
|
|
@ -433,9 +284,6 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
// Re-apply bottomPaddingFraction once we have a concrete height.
|
||||
updateSubtitleRenderingMode()
|
||||
applySubtitleStyle(localStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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",
|
||||
"date-fns": "^4.1.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "^54",
|
||||
"expo": "^54.0.33",
|
||||
"expo-application": "~7.0.7",
|
||||
"expo-auth-session": "~7.0.8",
|
||||
"expo-blur": "~15.0.7",
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export interface AppSettings {
|
|||
tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code)
|
||||
episodeLayoutStyle: 'vertical' | 'horizontal'; // Layout style for episode cards
|
||||
autoplayBestStream: boolean; // Automatically play the best available stream
|
||||
autoplayPreferredQuality: string; // Preferred quality for autoplay (e.g., '4K', '1080p', '720p', '480p')
|
||||
autoplayPreferredLanguage: string; // Preferred language for autoplay (e.g., 'English', 'Spanish', 'Any')
|
||||
// Local scraper settings
|
||||
scraperRepositoryUrl: string; // URL to the scraper repository
|
||||
enableLocalScrapers: boolean; // Enable/disable local scraper functionality
|
||||
|
|
@ -137,6 +139,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||
tmdbLanguagePreference: 'en', // Default to English
|
||||
episodeLayoutStyle: 'vertical', // Default to vertical layout for new installs
|
||||
autoplayBestStream: false, // Disabled by default for user choice
|
||||
autoplayPreferredQuality: '1080p', // Default to 1080p
|
||||
autoplayPreferredLanguage: 'Any', // Default to Any language
|
||||
// Local scraper defaults
|
||||
scraperRepositoryUrl: '',
|
||||
enableLocalScrapers: true,
|
||||
|
|
|
|||
|
|
@ -641,18 +641,6 @@
|
|||
"chinese": "Chinese (Simplified)",
|
||||
"hindi": "Hindi",
|
||||
"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",
|
||||
"content_discovery": "Content & Discovery",
|
||||
"appearance": "Appearance",
|
||||
|
|
@ -774,13 +762,13 @@
|
|||
"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.",
|
||||
"disable_error_reporting_title": "Disable Error Reporting?",
|
||||
"disable_error_reporting_message": "Disabling error reporting means we won’t 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_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_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_message": "This will disable all analytics, error reporting, and session replay. We won’t 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",
|
||||
"all_disabled_title": "All Telemetry Disabled",
|
||||
"all_disabled_message": "All data collection has been disabled. Changes take effect on next app restart.",
|
||||
|
|
@ -913,8 +901,8 @@
|
|||
},
|
||||
"debrid": {
|
||||
"title": "Debrid Integration",
|
||||
"description_torbox": "Connect Torbox to use your account-based source preferences. Enter your API key below to configure the integration.",
|
||||
"description_torrentio": "Configure Torrentio as an external source integration. A compatible debrid account may be required depending on your setup.",
|
||||
"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 to get torrent streams for movies and TV shows. A debrid service is required to stream content.",
|
||||
"tab_torbox": "TorBox",
|
||||
"tab_torrentio": "Torrentio",
|
||||
"status_connected": "Connected",
|
||||
|
|
@ -941,15 +929,15 @@
|
|||
"enter_api_key": "Enter your API Key",
|
||||
"connect_button": "Connect & Install",
|
||||
"connecting": "Connecting...",
|
||||
"unlock_speeds_title": "Optional Torbox Subscription",
|
||||
"unlock_speeds_desc": "Torbox offers account tiers with enhanced performance and availability features.",
|
||||
"unlock_speeds_title": "Unlock Premium Speeds",
|
||||
"unlock_speeds_desc": "Get a Torbox subscription to access cached high-quality streams with zero buffering.",
|
||||
"get_subscription": "Get Subscription",
|
||||
"powered_by": "Powered by",
|
||||
"disclaimer_torbox": "Nuvio is not affiliated with Torbox in any way.",
|
||||
"disclaimer_torrentio": "Nuvio is not affiliated with Torrentio in any way.",
|
||||
"installed_badge": "✓ INSTALLED",
|
||||
"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",
|
||||
"service_label": "Debrid Service *",
|
||||
"api_key_label": "API Key *",
|
||||
|
|
@ -1343,7 +1331,7 @@
|
|||
"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.",
|
||||
"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_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."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
{Platform.OS === 'android' && !settings.useExternalPlayer && (
|
||||
<>
|
||||
|
|
@ -653,6 +611,12 @@ const styles = StyleSheet.create({
|
|||
paddingHorizontal: 52,
|
||||
gap: 8,
|
||||
},
|
||||
optionButtonsRowScroll: {
|
||||
paddingHorizontal: 52,
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
optionButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
|
|
@ -662,6 +626,10 @@ const styles = StyleSheet.create({
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
optionButtonLanguage: {
|
||||
minWidth: 90,
|
||||
flex: 0,
|
||||
},
|
||||
optionButtonWide: {
|
||||
flex: 1.5,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -70,6 +70,23 @@ const SUBTITLE_SOURCE_OPTIONS = [
|
|||
{ 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
|
||||
interface PlaybackSettingsContentProps {
|
||||
isTablet?: boolean;
|
||||
|
|
@ -159,30 +176,55 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
|||
const audioLanguageSheetRef = useRef<BottomSheetModal>(null);
|
||||
const subtitleLanguageSheetRef = useRef<BottomSheetModal>(null);
|
||||
const subtitleSourceSheetRef = useRef<BottomSheetModal>(null);
|
||||
const autoplayQualitySheetRef = useRef<BottomSheetModal>(null);
|
||||
const autoplayLanguageSheetRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
// Snap points
|
||||
const languageSnapPoints = useMemo(() => ['70%'], []);
|
||||
const sourceSnapPoints = useMemo(() => ['45%'], []);
|
||||
const qualitySnapPoints = useMemo(() => ['45%'], []);
|
||||
|
||||
// Handlers to present sheets - ensure only one is open at a time
|
||||
const openAudioLanguageSheet = useCallback(() => {
|
||||
subtitleLanguageSheetRef.current?.dismiss();
|
||||
subtitleSourceSheetRef.current?.dismiss();
|
||||
autoplayQualitySheetRef.current?.dismiss();
|
||||
autoplayLanguageSheetRef.current?.dismiss();
|
||||
setTimeout(() => audioLanguageSheetRef.current?.present(), 100);
|
||||
}, []);
|
||||
|
||||
const openSubtitleLanguageSheet = useCallback(() => {
|
||||
audioLanguageSheetRef.current?.dismiss();
|
||||
subtitleSourceSheetRef.current?.dismiss();
|
||||
autoplayQualitySheetRef.current?.dismiss();
|
||||
autoplayLanguageSheetRef.current?.dismiss();
|
||||
setTimeout(() => subtitleLanguageSheetRef.current?.present(), 100);
|
||||
}, []);
|
||||
|
||||
const openSubtitleSourceSheet = useCallback(() => {
|
||||
audioLanguageSheetRef.current?.dismiss();
|
||||
subtitleLanguageSheetRef.current?.dismiss();
|
||||
autoplayQualitySheetRef.current?.dismiss();
|
||||
autoplayLanguageSheetRef.current?.dismiss();
|
||||
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) => {
|
||||
if (!config?.items) return true;
|
||||
const item = config.items[itemId];
|
||||
|
|
@ -234,6 +276,16 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
|||
subtitleSourceSheetRef.current?.dismiss();
|
||||
};
|
||||
|
||||
const handleSelectAutoplayQuality = (quality: string) => {
|
||||
updateSetting('autoplayPreferredQuality', quality);
|
||||
autoplayQualitySheetRef.current?.dismiss();
|
||||
};
|
||||
|
||||
const handleSelectAutoplayLanguage = (language: string) => {
|
||||
updateSetting('autoplayPreferredLanguage', language);
|
||||
autoplayLanguageSheetRef.current?.dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasVisibleItems(['video_player']) && (
|
||||
|
|
@ -256,6 +308,38 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
|||
)}
|
||||
|
||||
<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
|
||||
title={t('player.skip_intro_settings_title', { defaultValue: 'Skip Intro' })}
|
||||
description={t('player.powered_by_introdb', { defaultValue: 'Powered by IntroDB' })}
|
||||
|
|
@ -536,6 +620,95 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
|||
})}
|
||||
</BottomSheetScrollView>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { TABLET_BREAKPOINT } from './constants';
|
|||
import {
|
||||
filterStreamsByQuality,
|
||||
filterStreamsByLanguage,
|
||||
getLanguageVariations,
|
||||
getQualityNumeric,
|
||||
inferVideoTypeFromUrl,
|
||||
sortStreamsByQuality,
|
||||
|
|
@ -246,20 +247,77 @@ export const useStreamsScreen = () => {
|
|||
|
||||
if (allStreams.length === 0) return null;
|
||||
|
||||
// Sort primarily by provider priority, then respect the addon's internal order (originalIndex)
|
||||
// This ensures if an addon lists 1080p before 4K, we pick 1080p
|
||||
allStreams.sort((a, b) => {
|
||||
// Map preferred quality to numeric value
|
||||
const targetQuality = settings.autoplayPreferredQuality === '4K' ? 2160 : parseInt(settings.autoplayPreferredQuality, 10) || 1080;
|
||||
const preferredLanguage = settings.autoplayPreferredLanguage;
|
||||
|
||||
// 1. Try to find streams matching preferred language
|
||||
// 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;
|
||||
return a.originalIndex - b.originalIndex;
|
||||
});
|
||||
|
||||
const selected = languageMatchedStreams[0];
|
||||
logger.log(
|
||||
`🎯 Best stream selected: ${allStreams[0].stream.name || allStreams[0].stream.title} (Quality: ${allStreams[0].quality}p)`
|
||||
`🎯 Best stream selected: ${selected.stream.name || selected.stream.title} (Lang: ${selected.stream.lang || 'unknown'}, Quality: ${selected.quality}p, Target: ${targetQuality}p)`
|
||||
);
|
||||
|
||||
return allStreams[0].stream;
|
||||
return selected.stream;
|
||||
},
|
||||
[filterByQuality, filterByLanguage]
|
||||
[filterByQuality, filterByLanguage, settings.autoplayPreferredQuality, settings.autoplayPreferredLanguage]
|
||||
);
|
||||
|
||||
// Current episode
|
||||
|
|
|
|||
|
|
@ -89,27 +89,36 @@ export const filterStreamsByLanguage = (
|
|||
|
||||
/**
|
||||
* 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 => {
|
||||
if (!title) return 0;
|
||||
|
||||
// Check for 4K first (treat as 2160p)
|
||||
if (/\b4k\b/i.test(title)) {
|
||||
if (/\b(4k|uhd)\b/i.test(title)) {
|
||||
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);
|
||||
if (matchWithP) return parseInt(matchWithP[1], 10);
|
||||
|
||||
const qualityPatterns = [/\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i];
|
||||
|
||||
for (const pattern of qualityPatterns) {
|
||||
// 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 commonResolutions) {
|
||||
const match = title.match(pattern);
|
||||
if (match) {
|
||||
const quality = parseInt(match[1], 10);
|
||||
if (quality >= 240 && quality <= 8000) return quality;
|
||||
if (quality >= 140 && quality <= 8000) return quality;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue