Merge pull request #295 from tapframe/mpv

Mpv
This commit is contained in:
Nayif 2025-12-24 19:33:47 +05:30 committed by GitHub
commit 441e8d8656
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 8095 additions and 7638 deletions

1
.gitignore vendored
View file

@ -80,6 +80,7 @@ bottomnav.md
mmkv.md
fix-android-scroll-lag-summary.md
server/cache-server
server/campaign-manager
carousal.md
node_modules
expofs.md

View file

@ -42,6 +42,7 @@ import { AccountProvider, useAccount } from './src/contexts/AccountContext';
import { ToastProvider } from './src/contexts/ToastContext';
import { mmkvStorage } from './src/services/mmkvStorage';
import AnnouncementOverlay from './src/components/AnnouncementOverlay';
import { CampaignManager } from './src/components/promotions/CampaignManager';
Sentry.init({
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
@ -232,6 +233,7 @@ const ThemedApp = () => {
onActionPress={handleNavigateToDebrid}
actionButtonText="Connect Now"
/>
<CampaignManager />
</View>
</DownloadsProvider>
</NavigationContainer>

View file

@ -246,6 +246,9 @@ dependencies {
// Include only FFmpeg decoder AAR to avoid duplicates with Maven Media3
implementation files("libs/lib-decoder-ffmpeg-release.aar")
// MPV Player library
implementation files("libs/libmpv-release.aar")
// Google Cast Framework
implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}"

View file

@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<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.SYSTEM_ALERT_WINDOW"/>

View file

@ -15,6 +15,7 @@ import com.facebook.react.defaults.DefaultReactNativeHost
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
import com.nuvio.app.mpv.MpvPackage
class MainApplication : Application(), ReactApplication {
@ -24,7 +25,7 @@ class MainApplication : Application(), ReactApplication {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
add(com.nuvio.app.mpv.MpvPackage())
}
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"

View file

@ -0,0 +1,480 @@
package com.nuvio.app.mpv
import android.content.Context
import android.graphics.SurfaceTexture
import android.util.AttributeSet
import android.util.Log
import android.view.Surface
import android.view.TextureView
import dev.jdtech.mpv.MPVLib
class MPVView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : TextureView(context, attrs, defStyleAttr), TextureView.SurfaceTextureListener, MPVLib.EventObserver {
companion object {
private const val TAG = "MPVView"
}
private var isMpvInitialized = false
private var pendingDataSource: String? = null
private var isPaused: Boolean = true
private var surface: Surface? = null
private var httpHeaders: Map<String, String>? = null
// Hardware decoding setting (default: false = software decoding)
var useHardwareDecoding: Boolean = false
// Flag to track if onLoad has been fired (prevents multiple fires for HLS streams)
private var hasLoadEventFired: Boolean = false
// Event listener for React Native
var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null
var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null
var onEndCallback: (() -> Unit)? = null
var onErrorCallback: ((message: String) -> Unit)? = null
var onTracksChangedCallback: ((audioTracks: List<Map<String, Any>>, subtitleTracks: List<Map<String, Any>>) -> Unit)? = null
init {
surfaceTextureListener = this
isOpaque = false
}
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
Log.d(TAG, "Surface texture available: ${width}x${height}")
try {
surface = Surface(surfaceTexture)
MPVLib.create(context.applicationContext)
initOptions()
MPVLib.init()
MPVLib.attachSurface(surface!!)
MPVLib.addObserver(this)
MPVLib.setPropertyString("android-surface-size", "${width}x${height}")
observeProperties()
isMpvInitialized = true
// If a data source was set before surface was ready, load it now
// Headers are already applied in initOptions() before init()
pendingDataSource?.let { url ->
loadFile(url)
pendingDataSource = null
}
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize MPV", e)
onErrorCallback?.invoke("MPV initialization failed: ${e.message}")
}
}
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
Log.d(TAG, "Surface texture size changed: ${width}x${height}")
if (isMpvInitialized) {
MPVLib.setPropertyString("android-surface-size", "${width}x${height}")
}
}
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
Log.d(TAG, "Surface texture destroyed")
if (isMpvInitialized) {
MPVLib.removeObserver(this)
MPVLib.detachSurface()
MPVLib.destroy()
isMpvInitialized = false
}
surface?.release()
surface = null
return true
}
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
// Called when the SurfaceTexture is updated via updateTexImage()
}
private fun initOptions() {
// Mobile-optimized profile
MPVLib.setOptionString("profile", "fast")
MPVLib.setOptionString("vo", "gpu")
MPVLib.setOptionString("gpu-context", "android")
MPVLib.setOptionString("opengl-es", "yes")
// Hardware decoding configuration
// 'mediacodec-copy' for hardware acceleration (GPU decoding, copies frames to CPU)
// 'no' for software decoding (more compatible, especially on emulators)
val hwdecValue = if (useHardwareDecoding) "mediacodec-copy" else "no"
Log.d(TAG, "Hardware decoding: $useHardwareDecoding, hwdec value: $hwdecValue")
MPVLib.setOptionString("hwdec", hwdecValue)
MPVLib.setOptionString("hwdec-codecs", "all")
// Audio output
MPVLib.setOptionString("ao", "audiotrack,opensles")
// Network caching for streaming
MPVLib.setOptionString("demuxer-max-bytes", "67108864") // 64MB
MPVLib.setOptionString("demuxer-max-back-bytes", "33554432") // 32MB
MPVLib.setOptionString("cache", "yes")
MPVLib.setOptionString("cache-secs", "30")
// Network options
MPVLib.setOptionString("network-timeout", "60") // 60 second timeout
// CRITICAL: Disable youtube-dl/yt-dlp hook
// The ytdl_hook incorrectly tries to parse HLS/direct URLs through youtube-dl
// which fails on Android since yt-dlp is not available, causing playback failure
MPVLib.setOptionString("ytdl", "no")
// CRITICAL: HTTP headers MUST be set as options before init()
// Apply headers if they were set before surface initialization
applyHttpHeadersAsOptions()
// FFmpeg HTTP protocol options for better compatibility
MPVLib.setOptionString("tls-verify", "no") // Disable TLS cert verification
MPVLib.setOptionString("http-reconnect", "yes") // Auto-reconnect on network issues
MPVLib.setOptionString("stream-reconnect", "yes") // Reconnect if stream drops
// CRITICAL: HLS demuxer options for proper VOD stream handling
// Without these, HLS streams may be treated as live and start from the end
// Note: Multiple lavf options separated by comma
MPVLib.setOptionString("demuxer-lavf-o", "live_start_index=0,prefer_x_start=1,http_persistent=0")
MPVLib.setOptionString("demuxer-seekable-cache", "yes") // Allow seeking in cached content
MPVLib.setOptionString("force-seekable", "yes") // Force stream to be seekable
// Increase probe/analyze duration to help detect full HLS duration
MPVLib.setOptionString("demuxer-lavf-probesize", "10000000") // 10MB probe size
MPVLib.setOptionString("demuxer-lavf-analyzeduration", "10") // 10 seconds analyze
// Subtitle configuration - CRITICAL for Android
MPVLib.setOptionString("sub-auto", "fuzzy") // Auto-load subtitles
MPVLib.setOptionString("sub-visibility", "yes") // Make subtitles visible by default
MPVLib.setOptionString("sub-font-size", "48") // Larger font size for mobile readability
MPVLib.setOptionString("sub-pos", "95") // Position at bottom (0-100, 100 = very bottom)
MPVLib.setOptionString("sub-color", "#FFFFFFFF") // White color
MPVLib.setOptionString("sub-border-size", "3") // Thicker border for readability
MPVLib.setOptionString("sub-border-color", "#FF000000") // Black border
MPVLib.setOptionString("sub-shadow-offset", "2") // Add shadow for better visibility
MPVLib.setOptionString("sub-shadow-color", "#80000000") // Semi-transparent black shadow
// Font configuration - point to Android system fonts for all language support
MPVLib.setOptionString("osd-fonts-dir", "/system/fonts")
MPVLib.setOptionString("sub-fonts-dir", "/system/fonts")
MPVLib.setOptionString("sub-font", "Roboto") // Default fallback font
// Allow embedded fonts in ASS/SSA but fallback to system fonts
MPVLib.setOptionString("embeddedfonts", "yes")
// Language/encoding support for various subtitle formats
MPVLib.setOptionString("sub-codepage", "auto") // Auto-detect encoding (supports UTF-8, Latin, CJK, etc.)
MPVLib.setOptionString("osc", "no") // Disable on screen controller
MPVLib.setOptionString("osd-level", "1")
// Critical for subtitle rendering on Android GPU
// blend-subtitles=no lets the GPU renderer handle subtitle overlay properly
MPVLib.setOptionString("blend-subtitles", "no")
MPVLib.setOptionString("sub-use-margins", "no")
// Use 'scale' to allow ASS styling but with our scale and font overrides
// This preserves styled subtitles while having font fallbacks
MPVLib.setOptionString("sub-ass-override", "scale")
MPVLib.setOptionString("sub-scale", "1.0")
MPVLib.setOptionString("sub-fix-timing", "yes") // Fix timing for SRT subtitles
// Force subtitle rendering
MPVLib.setOptionString("sid", "auto") // Auto-select subtitle track
// Disable terminal/input
MPVLib.setOptionString("terminal", "no")
MPVLib.setOptionString("input-default-bindings", "no")
}
private fun observeProperties() {
// MPV format constants (from MPVLib source)
val MPV_FORMAT_NONE = 0
val MPV_FORMAT_FLAG = 3
val MPV_FORMAT_INT64 = 4
val MPV_FORMAT_DOUBLE = 5
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
MPVLib.observeProperty("duration/full", MPV_FORMAT_DOUBLE) // Use /full for complete HLS duration
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
MPVLib.observeProperty("eof-reached", MPV_FORMAT_FLAG)
MPVLib.observeProperty("video-params/aspect", MPV_FORMAT_DOUBLE)
MPVLib.observeProperty("width", MPV_FORMAT_INT64)
MPVLib.observeProperty("height", MPV_FORMAT_INT64)
MPVLib.observeProperty("track-list", MPV_FORMAT_NONE)
// Observe subtitle properties for debugging
MPVLib.observeProperty("sid", MPV_FORMAT_INT64)
MPVLib.observeProperty("sub-visibility", MPV_FORMAT_FLAG)
MPVLib.observeProperty("sub-text", MPV_FORMAT_NONE)
}
private fun loadFile(url: String) {
Log.d(TAG, "Loading file: $url")
// Reset load event flag for new file
hasLoadEventFired = false
MPVLib.command(arrayOf("loadfile", url))
}
// Public API
fun setDataSource(url: String) {
if (isMpvInitialized) {
// Headers were already set during initialization in initOptions()
loadFile(url)
} else {
pendingDataSource = url
}
}
fun setHeaders(headers: Map<String, String>?) {
httpHeaders = headers
Log.d(TAG, "Headers set: $headers")
}
private fun applyHttpHeadersAsOptions() {
// Always set user-agent (this works reliably)
val userAgent = httpHeaders?.get("User-Agent")
?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
Log.d(TAG, "Setting User-Agent: $userAgent")
MPVLib.setOptionString("user-agent", userAgent)
// Additionally, set other headers via http-header-fields if present
// This is needed for streams that require Referer, Origin, Cookie, etc.
httpHeaders?.let { headers ->
val otherHeaders = headers.filterKeys { it != "User-Agent" }
if (otherHeaders.isNotEmpty()) {
// Format as comma-separated "Key: Value" pairs
val headerString = otherHeaders.map { (key, value) -> "$key: $value" }.joinToString(",")
Log.d(TAG, "Setting additional headers: $headerString")
MPVLib.setOptionString("http-header-fields", headerString)
}
}
}
fun setPaused(paused: Boolean) {
isPaused = paused
if (isMpvInitialized) {
MPVLib.setPropertyBoolean("pause", paused)
}
}
fun seekTo(positionSeconds: Double) {
Log.d(TAG, "seekTo called: positionSeconds=$positionSeconds, isMpvInitialized=$isMpvInitialized")
if (isMpvInitialized) {
Log.d(TAG, "Executing MPV seek command: seek $positionSeconds absolute")
MPVLib.command(arrayOf("seek", positionSeconds.toString(), "absolute"))
}
}
fun setSpeed(speed: Double) {
if (isMpvInitialized) {
MPVLib.setPropertyDouble("speed", speed)
}
}
fun setVolume(volume: Double) {
if (isMpvInitialized) {
// MPV volume is 0-100
MPVLib.setPropertyDouble("volume", volume * 100.0)
}
}
fun setAudioTrack(trackId: Int) {
if (isMpvInitialized) {
if (trackId == -1) {
MPVLib.setPropertyString("aid", "no")
} else {
MPVLib.setPropertyInt("aid", trackId)
}
}
}
fun setSubtitleTrack(trackId: Int) {
Log.d(TAG, "setSubtitleTrack called: trackId=$trackId, isMpvInitialized=$isMpvInitialized")
if (isMpvInitialized) {
if (trackId == -1) {
Log.d(TAG, "Disabling subtitles (sid=no)")
MPVLib.setPropertyString("sid", "no")
MPVLib.setPropertyString("sub-visibility", "no")
} else {
Log.d(TAG, "Setting subtitle track to: $trackId")
MPVLib.setPropertyInt("sid", trackId)
// Ensure subtitles are visible
MPVLib.setPropertyString("sub-visibility", "yes")
// Debug: Verify the subtitle was set correctly
val currentSid = MPVLib.getPropertyInt("sid")
val subVisibility = MPVLib.getPropertyString("sub-visibility")
val subDelay = MPVLib.getPropertyDouble("sub-delay")
val subScale = MPVLib.getPropertyDouble("sub-scale")
Log.d(TAG, "After setting - sid=$currentSid, sub-visibility=$subVisibility, sub-delay=$subDelay, sub-scale=$subScale")
}
}
}
fun setResizeMode(mode: String) {
Log.d(TAG, "setResizeMode called: mode=$mode, isMpvInitialized=$isMpvInitialized")
if (isMpvInitialized) {
when (mode) {
"contain" -> {
// Letterbox - show entire video with black bars
MPVLib.setPropertyDouble("panscan", 0.0)
MPVLib.setPropertyString("keepaspect", "yes")
}
"cover" -> {
// Fill/crop - zoom to fill, cropping edges
MPVLib.setPropertyDouble("panscan", 1.0)
MPVLib.setPropertyString("keepaspect", "yes")
}
"stretch" -> {
// Stretch - disable aspect ratio
MPVLib.setPropertyDouble("panscan", 0.0)
MPVLib.setPropertyString("keepaspect", "no")
}
else -> {
// Default to contain
MPVLib.setPropertyDouble("panscan", 0.0)
MPVLib.setPropertyString("keepaspect", "yes")
}
}
}
}
// MPVLib.EventObserver implementation
override fun eventProperty(property: String) {
Log.d(TAG, "Property changed: $property")
when (property) {
"track-list" -> {
// Parse track list and notify React Native
parseAndSendTracks()
}
}
}
private fun parseAndSendTracks() {
try {
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
Log.d(TAG, "Track count: $trackCount")
val audioTracks = mutableListOf<Map<String, Any>>()
val subtitleTracks = mutableListOf<Map<String, Any>>()
for (i in 0 until trackCount) {
val type = MPVLib.getPropertyString("track-list/$i/type") ?: continue
val id = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
val title = MPVLib.getPropertyString("track-list/$i/title") ?: ""
val lang = MPVLib.getPropertyString("track-list/$i/lang") ?: ""
val codec = MPVLib.getPropertyString("track-list/$i/codec") ?: ""
val trackName = when {
title.isNotEmpty() -> title
lang.isNotEmpty() -> lang.uppercase()
else -> "Track $id"
}
val track = mapOf(
"id" to id,
"name" to trackName,
"language" to lang,
"codec" to codec
)
when (type) {
"audio" -> {
Log.d(TAG, "Found audio track: $track")
audioTracks.add(track)
}
"sub" -> {
Log.d(TAG, "Found subtitle track: $track")
subtitleTracks.add(track)
}
}
}
Log.d(TAG, "Sending tracks - Audio: ${audioTracks.size}, Subtitles: ${subtitleTracks.size}")
onTracksChangedCallback?.invoke(audioTracks, subtitleTracks)
} catch (e: Exception) {
Log.e(TAG, "Error parsing tracks", e)
}
}
override fun eventProperty(property: String, value: Long) {
Log.d(TAG, "Property $property = $value (Long)")
}
override fun eventProperty(property: String, value: Double) {
Log.d(TAG, "Property $property = $value (Double)")
when (property) {
"time-pos" -> {
val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0
onProgressCallback?.invoke(value, duration)
}
"duration/full", "duration" -> {
// Only fire onLoad once when video dimensions are available
// For HLS streams, duration updates incrementally as segments are fetched
if (!hasLoadEventFired) {
val width = MPVLib.getPropertyInt("width") ?: 0
val height = MPVLib.getPropertyInt("height") ?: 0
// Wait until we have valid dimensions before firing onLoad
if (width > 0 && height > 0 && value > 0) {
hasLoadEventFired = true
Log.d(TAG, "Firing onLoad event: duration=$value, width=$width, height=$height")
onLoadCallback?.invoke(value, width, height)
}
}
}
}
}
override fun eventProperty(property: String, value: Boolean) {
Log.d(TAG, "Property $property = $value (Boolean)")
when (property) {
"eof-reached" -> {
if (value) {
onEndCallback?.invoke()
}
}
}
}
override fun eventProperty(property: String, value: String) {
Log.d(TAG, "Property $property = $value (String)")
}
override fun event(eventId: Int) {
Log.d(TAG, "Event: $eventId")
// MPV event constants (from MPVLib source)
val MPV_EVENT_FILE_LOADED = 8
val MPV_EVENT_END_FILE = 7
when (eventId) {
MPV_EVENT_FILE_LOADED -> {
// File is loaded, start playback if not paused
if (!isPaused) {
MPVLib.setPropertyBoolean("pause", false)
}
}
MPV_EVENT_END_FILE -> {
Log.d(TAG, "MPV_EVENT_END_FILE")
// Heuristic: If duration is effectively 0 at end of file, it's a load error
val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0
val timePos = MPVLib.getPropertyDouble("time-pos") ?: 0.0
val eofReached = MPVLib.getPropertyBoolean("eof-reached") ?: false
Log.d(TAG, "End stats - Duration: $duration, Time: $timePos, EOF: $eofReached")
if (duration < 1.0 && !eofReached) {
val customError = "Unable to play media. Source may be unreachable."
Log.e(TAG, "Playback error detected (heuristic): $customError")
onErrorCallback?.invoke(customError)
} else {
onEndCallback?.invoke()
}
}
}
}
}

View file

@ -0,0 +1,16 @@
package com.nuvio.app.mpv
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class MpvPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return emptyList()
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return listOf(MpvPlayerViewManager(reactContext))
}
}

View file

@ -0,0 +1,188 @@
package com.nuvio.app.mpv
import android.graphics.Color
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.common.MapBuilder
import com.facebook.react.uimanager.SimpleViewManager
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.annotations.ReactProp
import com.facebook.react.uimanager.events.RCTEventEmitter
class MpvPlayerViewManager(
private val reactContext: ReactApplicationContext
) : SimpleViewManager<MPVView>() {
companion object {
const val REACT_CLASS = "MpvPlayer"
// Commands
const val COMMAND_SEEK = 1
const val COMMAND_SET_AUDIO_TRACK = 2
const val COMMAND_SET_SUBTITLE_TRACK = 3
}
override fun getName(): String = REACT_CLASS
override fun createViewInstance(context: ThemedReactContext): MPVView {
val view = MPVView(context)
// Note: Do NOT set background color - it will block the SurfaceView content
// Set up event callbacks
view.onLoadCallback = { duration, width, height ->
val event = Arguments.createMap().apply {
putDouble("duration", duration)
putInt("width", width)
putInt("height", height)
}
sendEvent(context, view.id, "onLoad", event)
}
view.onProgressCallback = { position, duration ->
val event = Arguments.createMap().apply {
putDouble("currentTime", position)
putDouble("duration", duration)
}
sendEvent(context, view.id, "onProgress", event)
}
view.onEndCallback = {
sendEvent(context, view.id, "onEnd", Arguments.createMap())
}
view.onErrorCallback = { message ->
val event = Arguments.createMap().apply {
putString("error", message)
}
sendEvent(context, view.id, "onError", event)
}
view.onTracksChangedCallback = { audioTracks, subtitleTracks ->
val event = Arguments.createMap().apply {
val audioArray = Arguments.createArray()
audioTracks.forEach { track ->
val trackMap = Arguments.createMap().apply {
putInt("id", track["id"] as Int)
putString("name", track["name"] as String)
putString("language", track["language"] as String)
putString("codec", track["codec"] as String)
}
audioArray.pushMap(trackMap)
}
putArray("audioTracks", audioArray)
val subtitleArray = Arguments.createArray()
subtitleTracks.forEach { track ->
val trackMap = Arguments.createMap().apply {
putInt("id", track["id"] as Int)
putString("name", track["name"] as String)
putString("language", track["language"] as String)
putString("codec", track["codec"] as String)
}
subtitleArray.pushMap(trackMap)
}
putArray("subtitleTracks", subtitleArray)
}
sendEvent(context, view.id, "onTracksChanged", event)
}
return view
}
private fun sendEvent(context: ThemedReactContext, viewId: Int, eventName: String, params: com.facebook.react.bridge.WritableMap) {
context.getJSModule(RCTEventEmitter::class.java)
.receiveEvent(viewId, eventName, params)
}
override fun getExportedCustomBubblingEventTypeConstants(): Map<String, Any> {
return MapBuilder.builder<String, Any>()
.put("onLoad", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onLoad")))
.put("onProgress", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onProgress")))
.put("onEnd", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onEnd")))
.put("onError", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onError")))
.put("onTracksChanged", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onTracksChanged")))
.build()
}
override fun getCommandsMap(): Map<String, Int> {
return MapBuilder.of(
"seek", COMMAND_SEEK,
"setAudioTrack", COMMAND_SET_AUDIO_TRACK,
"setSubtitleTrack", COMMAND_SET_SUBTITLE_TRACK
)
}
override fun receiveCommand(view: MPVView, commandId: String?, args: ReadableArray?) {
android.util.Log.d("MpvPlayerViewManager", "receiveCommand: $commandId, args: $args")
when (commandId) {
"seek" -> {
val position = args?.getDouble(0)
android.util.Log.d("MpvPlayerViewManager", "Seek command received: position=$position")
position?.let { view.seekTo(it) }
}
"setAudioTrack" -> {
args?.getInt(0)?.let { view.setAudioTrack(it) }
}
"setSubtitleTrack" -> {
args?.getInt(0)?.let { view.setSubtitleTrack(it) }
}
}
}
// React Props
@ReactProp(name = "source")
fun setSource(view: MPVView, source: String?) {
source?.let { view.setDataSource(it) }
}
@ReactProp(name = "paused")
fun setPaused(view: MPVView, paused: Boolean) {
view.setPaused(paused)
}
@ReactProp(name = "volume", defaultFloat = 1.0f)
fun setVolume(view: MPVView, volume: Float) {
view.setVolume(volume.toDouble())
}
@ReactProp(name = "rate", defaultFloat = 1.0f)
fun setRate(view: MPVView, rate: Float) {
view.setSpeed(rate.toDouble())
}
// Handle backgroundColor prop to prevent crash from React Native style system
@ReactProp(name = "backgroundColor", customType = "Color")
fun setBackgroundColor(view: MPVView, color: Int?) {
// Intentionally ignoring - background color would block the TextureView content
// Leave the view transparent
}
@ReactProp(name = "resizeMode")
fun setResizeMode(view: MPVView, resizeMode: String?) {
view.setResizeMode(resizeMode ?: "contain")
}
@ReactProp(name = "headers")
fun setHeaders(view: MPVView, headers: com.facebook.react.bridge.ReadableMap?) {
if (headers != null) {
val headerMap = mutableMapOf<String, String>()
val iterator = headers.keySetIterator()
while (iterator.hasNextKey()) {
val key = iterator.nextKey()
headers.getString(key)?.let { value ->
headerMap[key] = value
}
}
view.setHeaders(headerMap)
} else {
view.setHeaders(null)
}
}
@ReactProp(name = "useHardwareDecoding")
fun setUseHardwareDecoding(view: MPVView, useHardwareDecoding: Boolean) {
view.useHardwareDecoding = useHardwareDecoding
}
}

View file

@ -83,13 +83,6 @@
"username": "nayifleo"
}
],
[
"expo-libvlc-player",
{
"localNetworkPermission": "Allow $(PRODUCT_NAME) to access your local network",
"supportsBackgroundPlayback": true
}
],
"react-native-bottom-tabs",
[
"react-native-google-cast",
@ -97,7 +90,8 @@
"receiverAppId": "CC1AD845",
"iosStartDiscoveryAfterFirstTapOnCastButton": true
}
]
],
"./plugins/mpv-bridge/withMpvBridge"
],
"updates": {
"enabled": true,

View file

@ -475,7 +475,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
PRODUCT_NAME = "Nuvio";
PRODUCT_NAME = Nuvio;
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@ -506,8 +506,8 @@
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
PRODUCT_NAME = "Nuvio";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
PRODUCT_NAME = Nuvio;
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";

View file

@ -213,6 +213,8 @@ PODS:
- ExpoModulesCore
- ExpoBrightness (14.0.8):
- ExpoModulesCore
- ExpoClipboard (8.0.8):
- ExpoModulesCore
- ExpoCrypto (15.0.8):
- ExpoModulesCore
- ExpoDevice (8.0.10):
@ -2749,6 +2751,7 @@ DEPENDENCIES:
- ExpoAsset (from `../node_modules/expo-asset/ios`)
- ExpoBlur (from `../node_modules/expo-blur/ios`)
- ExpoBrightness (from `../node_modules/expo-brightness/ios`)
- ExpoClipboard (from `../node_modules/expo-clipboard/ios`)
- ExpoCrypto (from `../node_modules/expo-crypto/ios`)
- ExpoDevice (from `../node_modules/expo-device/ios`)
- ExpoDocumentPicker (from `../node_modules/expo-document-picker/ios`)
@ -2912,6 +2915,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-blur/ios"
ExpoBrightness:
:path: "../node_modules/expo-brightness/ios"
ExpoClipboard:
:path: "../node_modules/expo-clipboard/ios"
ExpoCrypto:
:path: "../node_modules/expo-crypto/ios"
ExpoDevice:
@ -3171,6 +3176,7 @@ SPEC CHECKSUMS:
ExpoAsset: 23a958e97d3d340919fe6774db35d563241e6c03
ExpoBlur: b90747a3f22a8b6ceffd9cb0dc41a4184efdc656
ExpoBrightness: 46c980463e8a54b9ce77f923c4bff0bb0c9526e0
ExpoClipboard: b36b287d8356887844bb08ed5c84b5979bb4dd1e
ExpoCrypto: b6105ebaa15d6b38a811e71e43b52cd934945322
ExpoDevice: 6327c3c200816795708885adf540d26ecab83d1a
ExpoDocumentPicker: 7cd9e71a0f66fb19eb0a586d6f26eee1284692e0

1
libmpv-android Submodule

@ -0,0 +1 @@
Subproject commit 8c4778b5aad441bb0449a7f9b3d6d827fd3d6a2a

1
mpv-android Submodule

@ -0,0 +1 @@
Subproject commit 118cd1ed3d498265e44230e5dbb015bdd59f9dad

29
package-lock.json generated
View file

@ -42,6 +42,7 @@
"expo-auth-session": "~7.0.8",
"expo-blur": "~15.0.7",
"expo-brightness": "~14.0.7",
"expo-clipboard": "~8.0.8",
"expo-crypto": "~15.0.7",
"expo-dev-client": "~6.0.15",
"expo-device": "~8.0.9",
@ -53,6 +54,7 @@
"expo-libvlc-player": "^2.2.3",
"expo-linear-gradient": "~15.0.7",
"expo-localization": "~17.0.7",
"expo-navigation-bar": "~5.0.10",
"expo-notifications": "~0.32.12",
"expo-random": "^14.0.1",
"expo-screen-orientation": "~9.0.7",
@ -6326,6 +6328,17 @@
"react-native": "*"
}
},
"node_modules/expo-clipboard": {
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz",
"integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-constants": {
"version": "18.0.12",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.12.tgz",
@ -6590,6 +6603,22 @@
"react-native": "*"
}
},
"node_modules/expo-navigation-bar": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/expo-navigation-bar/-/expo-navigation-bar-5.0.10.tgz",
"integrity": "sha512-r9rdLw8mY6GPMQmVVOY/r1NBBw74DZefXHF60HxhRsdNI2kjc1wLdfWfR2rk4JVdOvdMDujnGrc9HQmqM3n8Jg==",
"license": "MIT",
"dependencies": {
"@react-native/normalize-colors": "0.81.5",
"debug": "^4.3.2",
"react-native-is-edge-to-edge": "^1.2.1"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-notifications": {
"version": "0.32.15",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.15.tgz",

View file

@ -42,6 +42,7 @@
"expo-auth-session": "~7.0.8",
"expo-blur": "~15.0.7",
"expo-brightness": "~14.0.7",
"expo-clipboard": "~8.0.8",
"expo-crypto": "~15.0.7",
"expo-dev-client": "~6.0.15",
"expo-device": "~8.0.9",
@ -50,9 +51,9 @@
"expo-glass-effect": "~0.1.4",
"expo-haptics": "~15.0.7",
"expo-intent-launcher": "~13.0.7",
"expo-libvlc-player": "^2.2.3",
"expo-linear-gradient": "~15.0.7",
"expo-localization": "~17.0.7",
"expo-navigation-bar": "~5.0.10",
"expo-notifications": "~0.32.12",
"expo-random": "^14.0.1",
"expo-screen-orientation": "~9.0.7",
@ -102,4 +103,4 @@
"xcode": "^3.0.1"
},
"private": true
}
}

View file

@ -0,0 +1,427 @@
package com.nuvio.app.mpv
import android.content.Context
import android.graphics.SurfaceTexture
import android.util.AttributeSet
import android.util.Log
import android.view.Surface
import android.view.TextureView
import dev.jdtech.mpv.MPVLib
class MPVView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : TextureView(context, attrs, defStyleAttr), TextureView.SurfaceTextureListener, MPVLib.EventObserver {
companion object {
private const val TAG = "MPVView"
}
private var isMpvInitialized = false
private var pendingDataSource: String? = null
private var isPaused: Boolean = true
private var surface: Surface? = null
private var httpHeaders: Map<String, String>? = null
// Event listener for React Native
var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null
var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null
var onEndCallback: (() -> Unit)? = null
var onErrorCallback: ((message: String) -> Unit)? = null
var onTracksChangedCallback: ((audioTracks: List<Map<String, Any>>, subtitleTracks: List<Map<String, Any>>) -> Unit)? = null
init {
surfaceTextureListener = this
isOpaque = false
}
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
Log.d(TAG, "Surface texture available: ${width}x${height}")
try {
surface = Surface(surfaceTexture)
MPVLib.create(context.applicationContext)
initOptions()
MPVLib.init()
MPVLib.attachSurface(surface!!)
MPVLib.addObserver(this)
MPVLib.setPropertyString("android-surface-size", "${width}x${height}")
observeProperties()
isMpvInitialized = true
// If a data source was set before surface was ready, load it now
pendingDataSource?.let { url ->
applyHttpHeaders()
loadFile(url)
pendingDataSource = null
}
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize MPV", e)
onErrorCallback?.invoke("MPV initialization failed: ${e.message}")
}
}
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
Log.d(TAG, "Surface texture size changed: ${width}x${height}")
if (isMpvInitialized) {
MPVLib.setPropertyString("android-surface-size", "${width}x${height}")
}
}
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
Log.d(TAG, "Surface texture destroyed")
if (isMpvInitialized) {
MPVLib.removeObserver(this)
MPVLib.detachSurface()
MPVLib.destroy()
isMpvInitialized = false
}
surface?.release()
surface = null
return true
}
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
// Called when the SurfaceTexture is updated via updateTexImage()
}
private fun initOptions() {
// Mobile-optimized profile
MPVLib.setOptionString("profile", "fast")
MPVLib.setOptionString("vo", "gpu")
MPVLib.setOptionString("gpu-context", "android")
MPVLib.setOptionString("opengl-es", "yes")
// Hardware decoding - use mediacodec-copy to allow subtitle overlay
// 'mediacodec-copy' copies frames to CPU memory which enables subtitle blending
MPVLib.setOptionString("hwdec", "auto")
MPVLib.setOptionString("hwdec-codecs", "all")
// Audio output
MPVLib.setOptionString("ao", "audiotrack,opensles")
// Network caching for streaming
MPVLib.setOptionString("demuxer-max-bytes", "67108864") // 64MB
MPVLib.setOptionString("demuxer-max-back-bytes", "33554432") // 32MB
MPVLib.setOptionString("cache", "yes")
MPVLib.setOptionString("cache-secs", "30")
// Network options
MPVLib.setOptionString("network-timeout", "60") // 60 second timeout
// Subtitle configuration - CRITICAL for Android
MPVLib.setOptionString("sub-auto", "fuzzy") // Auto-load subtitles
MPVLib.setOptionString("sub-visibility", "yes") // Make subtitles visible by default
MPVLib.setOptionString("sub-font-size", "48") // Larger font size for mobile readability
MPVLib.setOptionString("sub-pos", "95") // Position at bottom (0-100, 100 = very bottom)
MPVLib.setOptionString("sub-color", "#FFFFFFFF") // White color
MPVLib.setOptionString("sub-border-size", "3") // Thicker border for readability
MPVLib.setOptionString("sub-border-color", "#FF000000") // Black border
MPVLib.setOptionString("sub-shadow-offset", "2") // Add shadow for better visibility
MPVLib.setOptionString("sub-shadow-color", "#80000000") // Semi-transparent black shadow
// Font configuration - point to Android system fonts for all language support
MPVLib.setOptionString("osd-fonts-dir", "/system/fonts")
MPVLib.setOptionString("sub-fonts-dir", "/system/fonts")
MPVLib.setOptionString("sub-font", "Roboto") // Default fallback font
// Allow embedded fonts in ASS/SSA but fallback to system fonts
MPVLib.setOptionString("embeddedfonts", "yes")
// Language/encoding support for various subtitle formats
MPVLib.setOptionString("sub-codepage", "auto") // Auto-detect encoding (supports UTF-8, Latin, CJK, etc.)
MPVLib.setOptionString("osc", "no") // Disable on screen controller
MPVLib.setOptionString("osd-level", "1")
// Critical for subtitle rendering on Android GPU
// blend-subtitles=no lets the GPU renderer handle subtitle overlay properly
MPVLib.setOptionString("blend-subtitles", "no")
MPVLib.setOptionString("sub-use-margins", "no")
// Use 'scale' to allow ASS styling but with our scale and font overrides
// This preserves styled subtitles while having font fallbacks
MPVLib.setOptionString("sub-ass-override", "scale")
MPVLib.setOptionString("sub-scale", "1.0")
MPVLib.setOptionString("sub-fix-timing", "yes") // Fix timing for SRT subtitles
// Force subtitle rendering
MPVLib.setOptionString("sid", "auto") // Auto-select subtitle track
// Disable terminal/input
MPVLib.setOptionString("terminal", "no")
MPVLib.setOptionString("input-default-bindings", "no")
}
private fun observeProperties() {
// MPV format constants (from MPVLib source)
val MPV_FORMAT_NONE = 0
val MPV_FORMAT_FLAG = 3
val MPV_FORMAT_INT64 = 4
val MPV_FORMAT_DOUBLE = 5
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
MPVLib.observeProperty("duration/full", MPV_FORMAT_DOUBLE) // Use /full for complete HLS duration
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
MPVLib.observeProperty("eof-reached", MPV_FORMAT_FLAG)
MPVLib.observeProperty("video-params/aspect", MPV_FORMAT_DOUBLE)
MPVLib.observeProperty("width", MPV_FORMAT_INT64)
MPVLib.observeProperty("height", MPV_FORMAT_INT64)
MPVLib.observeProperty("track-list", MPV_FORMAT_NONE)
// Observe subtitle properties for debugging
MPVLib.observeProperty("sid", MPV_FORMAT_INT64)
MPVLib.observeProperty("sub-visibility", MPV_FORMAT_FLAG)
MPVLib.observeProperty("sub-text", MPV_FORMAT_NONE)
}
private fun loadFile(url: String) {
Log.d(TAG, "Loading file: $url")
MPVLib.command(arrayOf("loadfile", url))
}
// Public API
fun setDataSource(url: String) {
if (isMpvInitialized) {
// Apply headers before loading the file
applyHttpHeaders()
loadFile(url)
} else {
pendingDataSource = url
}
}
fun setHeaders(headers: Map<String, String>?) {
httpHeaders = headers
Log.d(TAG, "Headers set: $headers")
}
private fun applyHttpHeaders() {
httpHeaders?.let { headers ->
if (headers.isNotEmpty()) {
// Format headers for MPV: comma-separated "Key: Value" pairs
val headerList = headers.map { (key, value) -> "$key: $value" }
val headerString = headerList.joinToString(",")
Log.d(TAG, "Applying HTTP headers: $headerString")
MPVLib.setOptionString("http-header-fields", headerString)
}
}
}
fun setPaused(paused: Boolean) {
isPaused = paused
if (isMpvInitialized) {
MPVLib.setPropertyBoolean("pause", paused)
}
}
fun seekTo(positionSeconds: Double) {
Log.d(TAG, "seekTo called: positionSeconds=$positionSeconds, isMpvInitialized=$isMpvInitialized")
if (isMpvInitialized) {
Log.d(TAG, "Executing MPV seek command: seek $positionSeconds absolute")
MPVLib.command(arrayOf("seek", positionSeconds.toString(), "absolute"))
}
}
fun setSpeed(speed: Double) {
if (isMpvInitialized) {
MPVLib.setPropertyDouble("speed", speed)
}
}
fun setVolume(volume: Double) {
if (isMpvInitialized) {
// MPV volume is 0-100
MPVLib.setPropertyDouble("volume", volume * 100.0)
}
}
fun setAudioTrack(trackId: Int) {
if (isMpvInitialized) {
if (trackId == -1) {
MPVLib.setPropertyString("aid", "no")
} else {
MPVLib.setPropertyInt("aid", trackId)
}
}
}
fun setSubtitleTrack(trackId: Int) {
Log.d(TAG, "setSubtitleTrack called: trackId=$trackId, isMpvInitialized=$isMpvInitialized")
if (isMpvInitialized) {
if (trackId == -1) {
Log.d(TAG, "Disabling subtitles (sid=no)")
MPVLib.setPropertyString("sid", "no")
MPVLib.setPropertyString("sub-visibility", "no")
} else {
Log.d(TAG, "Setting subtitle track to: $trackId")
MPVLib.setPropertyInt("sid", trackId)
// Ensure subtitles are visible
MPVLib.setPropertyString("sub-visibility", "yes")
// Debug: Verify the subtitle was set correctly
val currentSid = MPVLib.getPropertyInt("sid")
val subVisibility = MPVLib.getPropertyString("sub-visibility")
val subDelay = MPVLib.getPropertyDouble("sub-delay")
val subScale = MPVLib.getPropertyDouble("sub-scale")
Log.d(TAG, "After setting - sid=$currentSid, sub-visibility=$subVisibility, sub-delay=$subDelay, sub-scale=$subScale")
}
}
}
fun setResizeMode(mode: String) {
Log.d(TAG, "setResizeMode called: mode=$mode, isMpvInitialized=$isMpvInitialized")
if (isMpvInitialized) {
when (mode) {
"contain" -> {
// Letterbox - show entire video with black bars
MPVLib.setPropertyDouble("panscan", 0.0)
MPVLib.setPropertyString("keepaspect", "yes")
}
"cover" -> {
// Fill/crop - zoom to fill, cropping edges
MPVLib.setPropertyDouble("panscan", 1.0)
MPVLib.setPropertyString("keepaspect", "yes")
}
"stretch" -> {
// Stretch - disable aspect ratio
MPVLib.setPropertyDouble("panscan", 0.0)
MPVLib.setPropertyString("keepaspect", "no")
}
else -> {
// Default to contain
MPVLib.setPropertyDouble("panscan", 0.0)
MPVLib.setPropertyString("keepaspect", "yes")
}
}
}
}
// MPVLib.EventObserver implementation
override fun eventProperty(property: String) {
Log.d(TAG, "Property changed: $property")
when (property) {
"track-list" -> {
// Parse track list and notify React Native
parseAndSendTracks()
}
}
}
private fun parseAndSendTracks() {
try {
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
Log.d(TAG, "Track count: $trackCount")
val audioTracks = mutableListOf<Map<String, Any>>()
val subtitleTracks = mutableListOf<Map<String, Any>>()
for (i in 0 until trackCount) {
val type = MPVLib.getPropertyString("track-list/$i/type") ?: continue
val id = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
val title = MPVLib.getPropertyString("track-list/$i/title") ?: ""
val lang = MPVLib.getPropertyString("track-list/$i/lang") ?: ""
val codec = MPVLib.getPropertyString("track-list/$i/codec") ?: ""
val trackName = when {
title.isNotEmpty() -> title
lang.isNotEmpty() -> lang.uppercase()
else -> "Track $id"
}
val track = mapOf(
"id" to id,
"name" to trackName,
"language" to lang,
"codec" to codec
)
when (type) {
"audio" -> {
Log.d(TAG, "Found audio track: $track")
audioTracks.add(track)
}
"sub" -> {
Log.d(TAG, "Found subtitle track: $track")
subtitleTracks.add(track)
}
}
}
Log.d(TAG, "Sending tracks - Audio: ${audioTracks.size}, Subtitles: ${subtitleTracks.size}")
onTracksChangedCallback?.invoke(audioTracks, subtitleTracks)
} catch (e: Exception) {
Log.e(TAG, "Error parsing tracks", e)
}
}
override fun eventProperty(property: String, value: Long) {
Log.d(TAG, "Property $property = $value (Long)")
}
override fun eventProperty(property: String, value: Double) {
Log.d(TAG, "Property $property = $value (Double)")
when (property) {
"time-pos" -> {
val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0
onProgressCallback?.invoke(value, duration)
}
"duration/full", "duration" -> {
val width = MPVLib.getPropertyInt("width") ?: 0
val height = MPVLib.getPropertyInt("height") ?: 0
onLoadCallback?.invoke(value, width, height)
}
}
}
override fun eventProperty(property: String, value: Boolean) {
Log.d(TAG, "Property $property = $value (Boolean)")
when (property) {
"eof-reached" -> {
if (value) {
onEndCallback?.invoke()
}
}
}
}
override fun eventProperty(property: String, value: String) {
Log.d(TAG, "Property $property = $value (String)")
}
override fun event(eventId: Int) {
Log.d(TAG, "Event: $eventId")
// MPV event constants (from MPVLib source)
val MPV_EVENT_FILE_LOADED = 8
val MPV_EVENT_END_FILE = 7
when (eventId) {
MPV_EVENT_FILE_LOADED -> {
// File is loaded, start playback if not paused
if (!isPaused) {
MPVLib.setPropertyBoolean("pause", false)
}
}
MPV_EVENT_END_FILE -> {
Log.d(TAG, "MPV_EVENT_END_FILE")
// Heuristic: If duration is effectively 0 at end of file, it's a load error
val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0
val timePos = MPVLib.getPropertyDouble("time-pos") ?: 0.0
val eofReached = MPVLib.getPropertyBoolean("eof-reached") ?: false
Log.d(TAG, "End stats - Duration: $duration, Time: $timePos, EOF: $eofReached")
if (duration < 1.0 && !eofReached) {
val customError = "Unable to play media. Source may be unreachable."
Log.e(TAG, "Playback error detected (heuristic): $customError")
onErrorCallback?.invoke(customError)
} else {
onEndCallback?.invoke()
}
}
}
}
}

View file

@ -0,0 +1,16 @@
package com.nuvio.app.mpv
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class MpvPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return emptyList()
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return listOf(MpvPlayerViewManager(reactContext))
}
}

View file

@ -0,0 +1,183 @@
package com.nuvio.app.mpv
import android.graphics.Color
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.common.MapBuilder
import com.facebook.react.uimanager.SimpleViewManager
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.annotations.ReactProp
import com.facebook.react.uimanager.events.RCTEventEmitter
class MpvPlayerViewManager(
private val reactContext: ReactApplicationContext
) : SimpleViewManager<MPVView>() {
companion object {
const val REACT_CLASS = "MpvPlayer"
// Commands
const val COMMAND_SEEK = 1
const val COMMAND_SET_AUDIO_TRACK = 2
const val COMMAND_SET_SUBTITLE_TRACK = 3
}
override fun getName(): String = REACT_CLASS
override fun createViewInstance(context: ThemedReactContext): MPVView {
val view = MPVView(context)
// Note: Do NOT set background color - it will block the SurfaceView content
// Set up event callbacks
view.onLoadCallback = { duration, width, height ->
val event = Arguments.createMap().apply {
putDouble("duration", duration)
putInt("width", width)
putInt("height", height)
}
sendEvent(context, view.id, "onLoad", event)
}
view.onProgressCallback = { position, duration ->
val event = Arguments.createMap().apply {
putDouble("currentTime", position)
putDouble("duration", duration)
}
sendEvent(context, view.id, "onProgress", event)
}
view.onEndCallback = {
sendEvent(context, view.id, "onEnd", Arguments.createMap())
}
view.onErrorCallback = { message ->
val event = Arguments.createMap().apply {
putString("error", message)
}
sendEvent(context, view.id, "onError", event)
}
view.onTracksChangedCallback = { audioTracks, subtitleTracks ->
val event = Arguments.createMap().apply {
val audioArray = Arguments.createArray()
audioTracks.forEach { track ->
val trackMap = Arguments.createMap().apply {
putInt("id", track["id"] as Int)
putString("name", track["name"] as String)
putString("language", track["language"] as String)
putString("codec", track["codec"] as String)
}
audioArray.pushMap(trackMap)
}
putArray("audioTracks", audioArray)
val subtitleArray = Arguments.createArray()
subtitleTracks.forEach { track ->
val trackMap = Arguments.createMap().apply {
putInt("id", track["id"] as Int)
putString("name", track["name"] as String)
putString("language", track["language"] as String)
putString("codec", track["codec"] as String)
}
subtitleArray.pushMap(trackMap)
}
putArray("subtitleTracks", subtitleArray)
}
sendEvent(context, view.id, "onTracksChanged", event)
}
return view
}
private fun sendEvent(context: ThemedReactContext, viewId: Int, eventName: String, params: com.facebook.react.bridge.WritableMap) {
context.getJSModule(RCTEventEmitter::class.java)
.receiveEvent(viewId, eventName, params)
}
override fun getExportedCustomBubblingEventTypeConstants(): Map<String, Any> {
return MapBuilder.builder<String, Any>()
.put("onLoad", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onLoad")))
.put("onProgress", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onProgress")))
.put("onEnd", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onEnd")))
.put("onError", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onError")))
.put("onTracksChanged", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onTracksChanged")))
.build()
}
override fun getCommandsMap(): Map<String, Int> {
return MapBuilder.of(
"seek", COMMAND_SEEK,
"setAudioTrack", COMMAND_SET_AUDIO_TRACK,
"setSubtitleTrack", COMMAND_SET_SUBTITLE_TRACK
)
}
override fun receiveCommand(view: MPVView, commandId: String?, args: ReadableArray?) {
android.util.Log.d("MpvPlayerViewManager", "receiveCommand: $commandId, args: $args")
when (commandId) {
"seek" -> {
val position = args?.getDouble(0)
android.util.Log.d("MpvPlayerViewManager", "Seek command received: position=$position")
position?.let { view.seekTo(it) }
}
"setAudioTrack" -> {
args?.getInt(0)?.let { view.setAudioTrack(it) }
}
"setSubtitleTrack" -> {
args?.getInt(0)?.let { view.setSubtitleTrack(it) }
}
}
}
// React Props
@ReactProp(name = "source")
fun setSource(view: MPVView, source: String?) {
source?.let { view.setDataSource(it) }
}
@ReactProp(name = "paused")
fun setPaused(view: MPVView, paused: Boolean) {
view.setPaused(paused)
}
@ReactProp(name = "volume", defaultFloat = 1.0f)
fun setVolume(view: MPVView, volume: Float) {
view.setVolume(volume.toDouble())
}
@ReactProp(name = "rate", defaultFloat = 1.0f)
fun setRate(view: MPVView, rate: Float) {
view.setSpeed(rate.toDouble())
}
// Handle backgroundColor prop to prevent crash from React Native style system
@ReactProp(name = "backgroundColor", customType = "Color")
fun setBackgroundColor(view: MPVView, color: Int?) {
// Intentionally ignoring - background color would block the TextureView content
// Leave the view transparent
}
@ReactProp(name = "resizeMode")
fun setResizeMode(view: MPVView, resizeMode: String?) {
view.setResizeMode(resizeMode ?: "contain")
}
@ReactProp(name = "headers")
fun setHeaders(view: MPVView, headers: com.facebook.react.bridge.ReadableMap?) {
if (headers != null) {
val headerMap = mutableMapOf<String, String>()
val iterator = headers.keySetIterator()
while (iterator.hasNextKey()) {
val key = iterator.nextKey()
headers.getString(key)?.let { value ->
headerMap[key] = value
}
}
view.setHeaders(headerMap)
} else {
view.setHeaders(null)
}
}
}

View file

@ -0,0 +1,94 @@
const { withDangerousMod, withMainApplication, withMainActivity } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
/**
* Copy MPV native files to android project
*/
function copyMpvFiles(projectRoot) {
const sourceDir = path.join(projectRoot, 'plugins', 'mpv-bridge', 'android', 'mpv');
const destDir = path.join(projectRoot, 'android', 'app', 'src', 'main', 'java', 'com', 'nuvio', 'app', 'mpv');
// Create destination directory if it doesn't exist
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
// Copy all files from source to destination
if (fs.existsSync(sourceDir)) {
const files = fs.readdirSync(sourceDir);
files.forEach(file => {
const srcFile = path.join(sourceDir, file);
const destFile = path.join(destDir, file);
if (fs.statSync(srcFile).isFile()) {
fs.copyFileSync(srcFile, destFile);
console.log(`[mpv-bridge] Copied ${file} to android project`);
}
});
}
}
/**
* Modify MainApplication.kt to include MpvPackage
*/
function withMpvMainApplication(config) {
return withMainApplication(config, async (config) => {
let contents = config.modResults.contents;
// Add import for MpvPackage
const mpvImport = 'import com.nuvio.app.mpv.MpvPackage';
if (!contents.includes(mpvImport)) {
// Add import after the last import statement
const lastImportIndex = contents.lastIndexOf('import ');
const endOfLastImport = contents.indexOf('\n', lastImportIndex);
contents = contents.slice(0, endOfLastImport + 1) + mpvImport + '\n' + contents.slice(endOfLastImport + 1);
}
// Add MpvPackage to the packages list
const packagesPattern = /override fun getPackages\(\): List<ReactPackage> \{[\s\S]*?return PackageList\(this\)\.packages\.apply \{/;
if (contents.match(packagesPattern) && !contents.includes('MpvPackage()')) {
contents = contents.replace(
packagesPattern,
(match) => match + '\n add(MpvPackage())'
);
}
config.modResults.contents = contents;
return config;
});
}
/**
* Modify MainActivity.kt to handle MPV lifecycle if needed
*/
function withMpvMainActivity(config) {
return withMainActivity(config, async (config) => {
// Currently no modifications needed for MainActivity
// But this hook is available for future enhancements
return config;
});
}
/**
* Main plugin function
*/
function withMpvBridge(config) {
// Copy native files during prebuild
config = withDangerousMod(config, [
'android',
async (config) => {
copyMpvFiles(config.modRequest.projectRoot);
return config;
},
]);
// Modify MainApplication to register the package
config = withMpvMainApplication(config);
// Modify MainActivity if needed
config = withMpvMainActivity(config);
return config;
}
module.exports = withMpvBridge;

View file

@ -649,7 +649,6 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
streamProvider: cachedStream.stream.addonId || cachedStream.stream.addonName || cachedStream.stream.name,
streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream',
headers: cachedStream.stream.headers || undefined,
forceVlc: false,
id: currentItem.id,
type: currentItem.type,
episodeId: episodeId,

View file

@ -977,7 +977,6 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
streamProvider: cachedStream.stream.addonId || cachedStream.stream.addonName || cachedStream.stream.name,
streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream',
headers: cachedStream.stream.headers || undefined,
forceVlc: false,
id: item.id,
type: item.type,
episodeId: episodeId,

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,364 +0,0 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { View, Dimensions } from 'react-native';
import { logger } from '../../utils/logger';
// Dynamic import to avoid iOS loading Android native module
let LibVlcPlayerViewComponent: any = null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require('expo-libvlc-player');
LibVlcPlayerViewComponent = mod?.LibVlcPlayerView || null;
} catch {
LibVlcPlayerViewComponent = null;
}
interface VlcVideoPlayerProps {
source: string;
volume: number;
playbackSpeed: number;
zoomScale: number;
resizeMode: 'contain' | 'cover' | 'none';
onLoad: (data: any) => void;
onProgress: (data: any) => void;
onSeek: (data: any) => void;
onEnd: () => void;
onError: (error: any) => void;
onTracksUpdate: (tracks: { audio: any[], subtitle: any[] }) => void;
selectedAudioTrack?: number | null;
selectedSubtitleTrack?: number | null;
restoreTime?: number | null;
forceRemount?: boolean;
key?: string;
}
interface VlcTrack {
id: number;
name: string;
language?: string;
}
export interface VlcPlayerRef {
seek: (timeInSeconds: number) => void;
pause: () => void;
play: () => void;
}
const VlcVideoPlayer = forwardRef<VlcPlayerRef, VlcVideoPlayerProps>(({
source,
volume,
playbackSpeed,
zoomScale,
resizeMode,
onLoad,
onProgress,
onSeek,
onEnd,
onError,
onTracksUpdate,
selectedAudioTrack,
selectedSubtitleTrack,
restoreTime,
forceRemount,
key,
}, ref) => {
const vlcRef = useRef<any>(null);
const [vlcActive, setVlcActive] = useState(true);
const [duration, setDuration] = useState<number>(0);
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
// Expose imperative methods to parent component
useImperativeHandle(ref, () => ({
seek: (timeInSeconds: number) => {
if (vlcRef.current && typeof vlcRef.current.seek === 'function') {
const fraction = Math.min(Math.max(timeInSeconds / (duration || 1), 0), 0.999);
vlcRef.current.seek(fraction);
logger.log(`[VLC] Seeked to ${timeInSeconds}s (${fraction.toFixed(3)})`);
}
},
pause: () => {
if (vlcRef.current && typeof vlcRef.current.pause === 'function') {
vlcRef.current.pause();
logger.log('[VLC] Paused');
}
},
play: () => {
if (vlcRef.current && typeof vlcRef.current.play === 'function') {
vlcRef.current.play();
logger.log('[VLC] Played');
}
}
}), [duration]);
// Compute aspect ratio string for VLC
const toVlcRatio = useCallback((w: number, h: number): string => {
const a = Math.max(1, Math.round(w));
const b = Math.max(1, Math.round(h));
const gcd = (x: number, y: number): number => (y === 0 ? x : gcd(y, x % y));
const g = gcd(a, b);
return `${Math.floor(a / g)}:${Math.floor(b / g)}`;
}, []);
const screenDimensions = Dimensions.get('screen');
const vlcAspectRatio = useMemo(() => {
// For VLC, no forced aspect ratio - let it preserve natural aspect
return undefined;
}, [resizeMode, screenDimensions.width, screenDimensions.height, toVlcRatio]);
const clientScale = useMemo(() => {
if (!videoAspectRatio || screenDimensions.width <= 0 || screenDimensions.height <= 0) {
return 1;
}
if (resizeMode === 'cover') {
const screenAR = screenDimensions.width / screenDimensions.height;
return Math.max(screenAR / videoAspectRatio, videoAspectRatio / screenAR);
}
return 1;
}, [resizeMode, videoAspectRatio, screenDimensions.width, screenDimensions.height]);
// VLC options for better playback
const vlcOptions = useMemo(() => {
return [
'--network-caching=2000',
'--clock-jitter=0',
'--http-reconnect',
'--sout-mux-caching=2000'
];
}, []);
// VLC tracks prop
const vlcTracks = useMemo(() => ({
audio: selectedAudioTrack,
video: 0, // Use first video track
subtitle: selectedSubtitleTrack
}), [selectedAudioTrack, selectedSubtitleTrack]);
const handleFirstPlay = useCallback((info: any) => {
try {
logger.log('[VLC] Video loaded, extracting tracks...');
logger.log('[AndroidVideoPlayer][VLC] Video loaded successfully');
// Process VLC tracks using optimized function
if (info?.tracks) {
processVlcTracks(info.tracks);
}
const lenSec = (info?.length ?? 0) / 1000;
const width = info?.width || 0;
const height = info?.height || 0;
setDuration(lenSec);
onLoad({ duration: lenSec, naturalSize: width && height ? { width, height } : undefined });
if (width > 0 && height > 0) {
setVideoAspectRatio(width / height);
}
// Restore playback position after remount (workaround for surface detach)
if (restoreTime !== undefined && restoreTime !== null && restoreTime > 0) {
setTimeout(() => {
if (vlcRef.current && typeof vlcRef.current.seek === 'function') {
const seekPosition = Math.min(restoreTime / lenSec, 0.999); // Convert to fraction
vlcRef.current.seek(seekPosition);
logger.log('[VLC] Seeked to restore position');
}
}, 500); // Small delay to ensure player is ready
}
} catch (e) {
logger.error('[VLC] onFirstPlay error:', e);
logger.warn('[AndroidVideoPlayer][VLC] onFirstPlay parse error', e);
}
}, [onLoad, restoreTime]);
const handlePositionChanged = useCallback((ev: any) => {
const pos = typeof ev?.position === 'number' ? ev.position : 0;
// We need duration to calculate current time, but it's not available here
// The parent component should handle this calculation
onProgress({ position: pos });
}, [onProgress]);
const handlePlaying = useCallback(() => {
setVlcActive(true);
}, []);
const handlePaused = useCallback(() => {
setVlcActive(false);
}, []);
const handleEndReached = useCallback(() => {
onEnd();
}, [onEnd]);
const handleEncounteredError = useCallback((e: any) => {
logger.error('[AndroidVideoPlayer][VLC] Encountered error:', e);
onError(e);
}, [onError]);
const handleBackground = useCallback(() => {
logger.log('[VLC] App went to background');
}, []);
const handleESAdded = useCallback((tracks: any) => {
try {
logger.log('[VLC] ES Added - processing tracks...');
processVlcTracks(tracks);
} catch (e) {
logger.error('[VLC] onESAdded error:', e);
logger.warn('[AndroidVideoPlayer][VLC] onESAdded parse error', e);
}
}, []);
// Format VLC tracks to match RN Video format - raw version
const formatVlcTracks = useCallback((vlcTracks: Array<{id: number, name: string}>): VlcTrack[] => {
if (!Array.isArray(vlcTracks)) return [];
return vlcTracks.map(track => {
// Just extract basic language info if available, but keep the full name
let language = undefined;
let displayName = track.name || `Track ${track.id + 1}`;
// Log the raw track data for debugging
if (__DEV__) {
logger.log(`[VLC] Raw track data:`, { id: track.id, name: track.name });
}
// Only extract language from brackets if present, but keep full name
const languageMatch = track.name?.match(/\[([^\]]+)\]/);
if (languageMatch && languageMatch[1]) {
language = languageMatch[1].trim();
}
return {
id: track.id,
name: displayName, // Show exactly what VLC provides
language: language
};
});
}, []);
// Optimized VLC track processing function with reduced JSON operations
const processVlcTracks = useCallback((tracks: any) => {
if (!tracks) return;
// Log raw VLC tracks data for debugging
if (__DEV__) {
logger.log(`[VLC] Raw tracks data:`, tracks);
}
const { audio = [], subtitle = [] } = tracks;
// Process audio tracks
if (Array.isArray(audio) && audio.length > 0) {
const formattedAudio = formatVlcTracks(audio);
if (__DEV__) {
logger.log(`[VLC] Audio tracks updated:`, formattedAudio.length);
}
}
// Process subtitle tracks
if (Array.isArray(subtitle) && subtitle.length > 0) {
const formattedSubs = formatVlcTracks(subtitle);
if (__DEV__) {
logger.log(`[VLC] Subtitle tracks updated:`, formattedSubs.length);
}
}
// Notify parent of track updates
onTracksUpdate({ audio, subtitle });
}, [formatVlcTracks, onTracksUpdate]);
// Process URL for VLC compatibility
const processUrlForVLC = useCallback((url: string): string => {
if (!url || typeof url !== 'string') {
logger.warn('[AndroidVideoPlayer][VLC] Invalid URL provided:', url);
return url || '';
}
try {
// Check if URL is already properly formatted
const urlObj = new URL(url);
// Handle special characters in the pathname that might cause issues
const pathname = urlObj.pathname;
const search = urlObj.search;
const hash = urlObj.hash;
// Decode and re-encode the pathname to handle double-encoding
const decodedPathname = decodeURIComponent(pathname);
const encodedPathname = encodeURI(decodedPathname);
// Reconstruct the URL
const processedUrl = `${urlObj.protocol}//${urlObj.host}${encodedPathname}${search}${hash}`;
logger.log(`[AndroidVideoPlayer][VLC] URL processed: ${url} -> ${processedUrl}`);
return processedUrl;
} catch (error) {
logger.warn(`[AndroidVideoPlayer][VLC] URL processing failed, using original: ${error}`);
return url;
}
}, []);
const processedSource = useMemo(() => processUrlForVLC(source), [source, processUrlForVLC]);
if (!LibVlcPlayerViewComponent) {
return (
<View style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#000'
}}>
{/* VLC not available fallback */}
</View>
);
}
return (
<View
style={{
position: 'absolute',
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
overflow: 'hidden'
}}
>
<LibVlcPlayerViewComponent
ref={vlcRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
transform: [{ scale: clientScale }]
}}
// Force remount when surfaces are recreated
key={key || 'vlc-default'}
source={processedSource}
aspectRatio={vlcAspectRatio}
// Let VLC auto-fit the video to the view to prevent flicker on mode changes
scale={0}
options={vlcOptions}
tracks={vlcTracks}
volume={Math.round(Math.max(0, Math.min(1, volume)) * 100)}
mute={false}
repeat={false}
rate={playbackSpeed}
autoplay={false}
onFirstPlay={handleFirstPlay}
onPositionChanged={handlePositionChanged}
onPlaying={handlePlaying}
onPaused={handlePaused}
onEndReached={handleEndReached}
onEncounteredError={handleEncounteredError}
onBackground={handleBackground}
onESAdded={handleESAdded}
/>
</View>
);
});
VlcVideoPlayer.displayName = 'VlcVideoPlayer';
export default VlcVideoPlayer;

View file

@ -0,0 +1,121 @@
import React, { useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
import { View, StyleSheet, requireNativeComponent, Platform, UIManager, findNodeHandle } from 'react-native';
// Only available on Android
const MpvPlayerNative = Platform.OS === 'android'
? requireNativeComponent<any>('MpvPlayer')
: null;
export interface MpvPlayerRef {
seek: (positionSeconds: number) => void;
setAudioTrack: (trackId: number) => void;
setSubtitleTrack: (trackId: number) => void;
}
export interface MpvPlayerProps {
source: string;
headers?: { [key: string]: string };
paused?: boolean;
volume?: number;
rate?: number;
resizeMode?: 'contain' | 'cover' | 'stretch';
style?: any;
onLoad?: (data: { duration: number; width: number; height: number }) => void;
onProgress?: (data: { currentTime: number; duration: number }) => void;
onEnd?: () => void;
onError?: (error: { error: string }) => void;
onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void;
useHardwareDecoding?: boolean;
}
const MpvPlayer = forwardRef<MpvPlayerRef, MpvPlayerProps>((props, ref) => {
const nativeRef = useRef<any>(null);
const dispatchCommand = useCallback((commandName: string, args: any[] = []) => {
if (nativeRef.current && Platform.OS === 'android') {
const handle = findNodeHandle(nativeRef.current);
if (handle) {
UIManager.dispatchViewManagerCommand(
handle,
commandName,
args
);
}
}
}, []);
useImperativeHandle(ref, () => ({
seek: (positionSeconds: number) => {
dispatchCommand('seek', [positionSeconds]);
},
setAudioTrack: (trackId: number) => {
dispatchCommand('setAudioTrack', [trackId]);
},
setSubtitleTrack: (trackId: number) => {
dispatchCommand('setSubtitleTrack', [trackId]);
},
}), [dispatchCommand]);
if (Platform.OS !== 'android' || !MpvPlayerNative) {
// Fallback for iOS or if native component is not available
return (
<View style={[styles.container, props.style, { backgroundColor: 'black' }]} />
);
}
// Debug logging removed to prevent console spam
const handleLoad = (event: any) => {
console.log('[MpvPlayer] Native onLoad event:', event?.nativeEvent);
props.onLoad?.(event?.nativeEvent);
};
const handleProgress = (event: any) => {
props.onProgress?.(event?.nativeEvent);
};
const handleEnd = (event: any) => {
console.log('[MpvPlayer] Native onEnd event');
props.onEnd?.();
};
const handleError = (event: any) => {
console.log('[MpvPlayer] Native onError event:', event?.nativeEvent);
props.onError?.(event?.nativeEvent);
};
const handleTracksChanged = (event: any) => {
console.log('[MpvPlayer] Native onTracksChanged event:', event?.nativeEvent);
props.onTracksChanged?.(event?.nativeEvent);
};
return (
<MpvPlayerNative
ref={nativeRef}
style={[styles.container, props.style]}
source={props.source}
headers={props.headers}
paused={props.paused ?? true}
volume={props.volume ?? 1.0}
rate={props.rate ?? 1.0}
resizeMode={props.resizeMode ?? 'contain'}
onLoad={handleLoad}
onProgress={handleProgress}
onEnd={handleEnd}
onError={handleError}
onTracksChanged={handleTracksChanged}
useHardwareDecoding={props.useHardwareDecoding ?? false}
/>
);
});
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'black',
},
});
MpvPlayer.displayName = 'MpvPlayer';
export default MpvPlayer;

View file

@ -0,0 +1,194 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import {
TapGestureHandler,
PanGestureHandler,
LongPressGestureHandler,
State
} from 'react-native-gesture-handler';
import { MaterialIcons } from '@expo/vector-icons';
import { styles as localStyles } from '../../utils/playerStyles';
interface GestureControlsProps {
screenDimensions: { width: number, height: number };
gestureControls: any;
onLongPressActivated: () => void;
onLongPressEnd: () => void;
onLongPressStateChange: (event: any) => void;
toggleControls: () => void;
showControls: boolean;
hideControls: () => void;
volume: number;
brightness: number;
controlsTimeout: React.MutableRefObject<NodeJS.Timeout | null>;
}
export const GestureControls: React.FC<GestureControlsProps> = ({
screenDimensions,
gestureControls,
onLongPressActivated,
onLongPressEnd,
onLongPressStateChange,
toggleControls,
showControls,
hideControls,
volume,
brightness,
controlsTimeout
}) => {
const getVolumeIcon = (value: number) => {
if (value === 0) return 'volume-off';
if (value < 0.3) return 'volume-mute';
if (value < 0.6) return 'volume-down';
return 'volume-up';
};
const getBrightnessIcon = (value: number) => {
if (value < 0.3) return 'brightness-low';
if (value < 0.7) return 'brightness-medium';
return 'brightness-high';
};
return (
<>
{/* Left side gesture handler - brightness + tap + long press */}
<LongPressGestureHandler
onActivated={onLongPressActivated}
onEnded={onLongPressEnd}
onHandlerStateChange={onLongPressStateChange}
minDurationMs={500}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<PanGestureHandler
onGestureEvent={gestureControls.onBrightnessGestureEvent}
activeOffsetY={[-10, 10]}
failOffsetX={[-30, 30]}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
maxPointers={1}
>
<TapGestureHandler
onActivated={toggleControls}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15,
left: 0,
width: screenDimensions.width * 0.4,
height: screenDimensions.height * 0.7,
zIndex: 10,
}} />
</TapGestureHandler>
</PanGestureHandler>
</LongPressGestureHandler>
{/* Right side gesture handler - volume + tap + long press */}
<LongPressGestureHandler
onActivated={onLongPressActivated}
onEnded={onLongPressEnd}
onHandlerStateChange={onLongPressStateChange}
minDurationMs={500}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<PanGestureHandler
onGestureEvent={gestureControls.onVolumeGestureEvent}
activeOffsetY={[-10, 10]}
failOffsetX={[-30, 30]}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
maxPointers={1}
>
<TapGestureHandler
onActivated={toggleControls}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15,
right: 0,
width: screenDimensions.width * 0.4,
height: screenDimensions.height * 0.7,
zIndex: 10,
}} />
</TapGestureHandler>
</PanGestureHandler>
</LongPressGestureHandler>
{/* Center area tap handler */}
<TapGestureHandler
onActivated={() => {
if (showControls) {
const timeoutId = setTimeout(() => {
hideControls();
}, 0);
if (controlsTimeout.current) {
clearTimeout(controlsTimeout.current);
}
controlsTimeout.current = timeoutId;
} else {
toggleControls();
}
}}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15,
left: screenDimensions.width * 0.4,
width: screenDimensions.width * 0.2,
height: screenDimensions.height * 0.7,
zIndex: 5,
}} />
</TapGestureHandler>
{/* Volume/Brightness Pill Overlay */}
{(gestureControls.showVolumeOverlay || gestureControls.showBrightnessOverlay) && (
<View style={localStyles.gestureIndicatorContainer}>
<View
style={[
localStyles.iconWrapper,
{
backgroundColor: gestureControls.showVolumeOverlay && volume === 0
? 'rgba(242, 184, 181)'
: 'rgba(59, 59, 59)'
}
]}
>
<MaterialIcons
name={
gestureControls.showVolumeOverlay
? getVolumeIcon(volume)
: getBrightnessIcon(brightness)
}
size={24}
color={
gestureControls.showVolumeOverlay && volume === 0
? 'rgba(96, 20, 16)'
: 'rgba(255, 255, 255)'
}
/>
</View>
<Text
style={[
localStyles.gestureText,
gestureControls.showVolumeOverlay && volume === 0 && { color: 'rgba(242, 184, 181)' }
]}
>
{gestureControls.showVolumeOverlay && volume === 0
? "Muted"
: `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%`
}
</Text>
</View>
)}
</>
);
};

View file

@ -0,0 +1,228 @@
import React, { useState, useRef } from 'react';
import { View, Text, TouchableOpacity, ScrollView, Animated, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface PauseOverlayProps {
visible: boolean;
onClose: () => void;
title: string;
episodeTitle?: string;
season?: number;
episode?: number;
year?: string | number;
type: string;
description: string;
cast: any[];
screenDimensions: { width: number, height: number };
}
export const PauseOverlay: React.FC<PauseOverlayProps> = ({
visible,
onClose,
title,
episodeTitle,
season,
episode,
year,
type,
description,
cast,
screenDimensions
}) => {
const insets = useSafeAreaInsets();
// Internal Animation State
const pauseOverlayOpacity = useRef(new Animated.Value(visible ? 1 : 0)).current;
const pauseOverlayTranslateY = useRef(new Animated.Value(12)).current;
const metadataOpacity = useRef(new Animated.Value(1)).current;
const metadataScale = useRef(new Animated.Value(1)).current;
// Cast Details State
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
const [showCastDetails, setShowCastDetails] = useState(false);
const castDetailsOpacity = useRef(new Animated.Value(0)).current;
const castDetailsScale = useRef(new Animated.Value(0.95)).current;
React.useEffect(() => {
Animated.timing(pauseOverlayOpacity, {
toValue: visible ? 1 : 0,
duration: 250,
useNativeDriver: true
}).start();
}, [visible]);
if (!visible && !showCastDetails) return null;
return (
<TouchableOpacity
activeOpacity={1}
onPress={onClose}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 30,
}}
>
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: pauseOverlayOpacity,
}}
>
{/* Horizontal Fade */}
<View style={{ position: 'absolute', top: 0, left: 0, bottom: 0, width: screenDimensions.width * 0.7 }}>
<LinearGradient
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
colors={['rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)']}
locations={[0, 1]}
style={StyleSheet.absoluteFill}
/>
</View>
<LinearGradient
colors={[
'rgba(0,0,0,0.6)',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.2)',
'rgba(0,0,0,0.0)'
]}
locations={[0, 0.3, 0.6, 1]}
style={StyleSheet.absoluteFill}
/>
<Animated.View style={{
position: 'absolute',
left: 24 + insets.left,
right: 24 + insets.right,
top: 24 + insets.top,
bottom: 110 + insets.bottom,
transform: [{ translateY: pauseOverlayTranslateY }]
}}>
{showCastDetails && selectedCastMember ? (
<Animated.View
style={{
flex: 1,
justifyContent: 'center',
opacity: castDetailsOpacity,
transform: [{ scale: castDetailsScale }]
}}
>
<View style={{ alignItems: 'flex-start', paddingBottom: screenDimensions.height * 0.1 }}>
<TouchableOpacity
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 24, paddingVertical: 8, paddingHorizontal: 4 }}
onPress={() => {
Animated.parallel([
Animated.timing(castDetailsOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(castDetailsScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
]).start(() => {
setShowCastDetails(false);
setSelectedCastMember(null);
Animated.parallel([
Animated.timing(metadataOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(metadataScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
]).start();
});
}}
>
<MaterialIcons name="arrow-back" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
<Text style={{ color: '#B8B8B8', fontSize: Math.min(14, screenDimensions.width * 0.02) }}>Back to details</Text>
</TouchableOpacity>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', width: '100%' }}>
{selectedCastMember.profile_path && (
<View style={{ marginRight: 20, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 5 }}>
<FastImage
source={{ uri: `https://image.tmdb.org/t/p/w300${selectedCastMember.profile_path}` }}
style={{ width: Math.min(120, screenDimensions.width * 0.18), height: Math.min(180, screenDimensions.width * 0.27), borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.1)' }}
resizeMode={FastImage.resizeMode.cover}
/>
</View>
)}
<View style={{ flex: 1, paddingTop: 8 }}>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(32, screenDimensions.width * 0.045), fontWeight: '800', marginBottom: 8 }} numberOfLines={2}>
{selectedCastMember.name}
</Text>
{selectedCastMember.character && (
<Text style={{ color: '#CCCCCC', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8, fontWeight: '500', fontStyle: 'italic' }} numberOfLines={2}>
as {selectedCastMember.character}
</Text>
)}
{selectedCastMember.biography && (
<Text style={{ color: '#D6D6D6', fontSize: Math.min(14, screenDimensions.width * 0.019), lineHeight: Math.min(20, screenDimensions.width * 0.026), marginTop: 16, opacity: 0.9 }} numberOfLines={4}>
{selectedCastMember.biography}
</Text>
)}
</View>
</View>
</View>
</Animated.View>
) : (
<Animated.View style={{ flex: 1, justifyContent: 'space-between', opacity: metadataOpacity, transform: [{ scale: metadataScale }] }}>
<View>
<Text style={{ color: '#B8B8B8', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }}>You're watching</Text>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(48, screenDimensions.width * 0.06), fontWeight: '800', marginBottom: 10 }} numberOfLines={2}>
{title}
</Text>
{!!year && (
<Text style={{ color: '#CCCCCC', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }} numberOfLines={1}>
{`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`}
</Text>
)}
{!!episodeTitle && (
<Text style={{ color: '#FFFFFF', fontSize: Math.min(20, screenDimensions.width * 0.03), fontWeight: '600', marginBottom: 8 }} numberOfLines={2}>
{episodeTitle}
</Text>
)}
{description && (
<Text style={{ color: '#D6D6D6', fontSize: Math.min(18, screenDimensions.width * 0.025), lineHeight: Math.min(24, screenDimensions.width * 0.03) }} numberOfLines={3}>
{description}
</Text>
)}
{cast && cast.length > 0 && (
<View style={{ marginTop: 16 }}>
<Text style={{ color: '#B8B8B8', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8 }}>Cast</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{cast.slice(0, 6).map((castMember: any, index: number) => (
<TouchableOpacity
key={castMember.id || index}
style={{ backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: Math.min(12, screenDimensions.width * 0.015), paddingVertical: Math.min(6, screenDimensions.height * 0.008), marginRight: 8, marginBottom: 8 }}
onPress={() => {
setSelectedCastMember(castMember);
Animated.parallel([
Animated.timing(metadataOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(metadataScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
]).start(() => {
setShowCastDetails(true);
Animated.parallel([
Animated.timing(castDetailsOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(castDetailsScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
]).start();
});
}}
>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(14, screenDimensions.width * 0.018) }}>
{castMember.name}
</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
</View>
</Animated.View>
)}
</Animated.View>
</Animated.View>
</TouchableOpacity>
);
};

View file

@ -0,0 +1,32 @@
import React from 'react';
import { View, Text, Animated, StyleSheet } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { styles } from '../../utils/playerStyles';
interface SpeedActivatedOverlayProps {
visible: boolean;
opacity: Animated.Value;
speed: number;
}
export const SpeedActivatedOverlay: React.FC<SpeedActivatedOverlayProps> = ({
visible,
opacity,
speed
}) => {
if (!visible) return null;
return (
<Animated.View
style={[
styles.speedActivatedOverlay,
{ opacity: opacity }
]}
>
<View style={styles.speedActivatedContainer}>
<MaterialIcons name="fast-forward" size={32} color="#FFFFFF" />
<Text style={styles.speedActivatedText}>{speed}x Speed</Text>
</View>
</Animated.View>
);
};

View file

@ -0,0 +1,156 @@
import React, { useCallback, memo } from 'react';
import { View, TouchableWithoutFeedback, StyleSheet } from 'react-native';
import { PinchGestureHandler } from 'react-native-gesture-handler';
import MpvPlayer, { MpvPlayerRef } from '../MpvPlayer';
import { styles } from '../../utils/playerStyles';
import { ResizeModeType } from '../../utils/playerTypes';
interface VideoSurfaceProps {
processedStreamUrl: string;
headers?: { [key: string]: string };
volume: number;
playbackSpeed: number;
resizeMode: ResizeModeType;
paused: boolean;
currentStreamUrl: string;
// Callbacks
toggleControls: () => void;
onLoad: (data: any) => void;
onProgress: (data: any) => void;
onSeek: (data: any) => void;
onEnd: () => void;
onError: (err: any) => void;
onBuffer: (buf: any) => void;
// Refs
mpvPlayerRef?: React.RefObject<MpvPlayerRef>;
pinchRef: any;
// Handlers
onPinchGestureEvent: any;
onPinchHandlerStateChange: any;
screenDimensions: { width: number, height: number };
onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void;
useHardwareDecoding?: boolean;
}
export const VideoSurface: React.FC<VideoSurfaceProps> = ({
processedStreamUrl,
headers,
volume,
playbackSpeed,
resizeMode,
paused,
currentStreamUrl,
toggleControls,
onLoad,
onProgress,
onSeek,
onEnd,
onError,
onBuffer,
mpvPlayerRef,
pinchRef,
onPinchGestureEvent,
onPinchHandlerStateChange,
screenDimensions,
onTracksChanged,
useHardwareDecoding,
}) => {
// Use the actual stream URL
const streamUrl = currentStreamUrl || processedStreamUrl;
// Debug logging removed to prevent console spam
const handleLoad = (data: { duration: number; width: number; height: number }) => {
console.log('[VideoSurface] onLoad received:', data);
onLoad({
duration: data.duration,
naturalSize: {
width: data.width,
height: data.height,
},
});
};
const handleProgress = (data: { currentTime: number; duration: number }) => {
onProgress({
currentTime: data.currentTime,
playableDuration: data.currentTime,
});
};
const handleError = (error: { error: string }) => {
console.log('[VideoSurface] onError received:', error);
onError({
error: {
errorString: error.error,
},
});
};
const handleEnd = () => {
console.log('[VideoSurface] onEnd received');
onEnd();
};
return (
<View style={[styles.videoContainer, {
width: screenDimensions.width,
height: screenDimensions.height,
}]}>
{/* MPV Player - rendered at the bottom of the z-order */}
<MpvPlayer
ref={mpvPlayerRef}
source={streamUrl}
headers={headers}
paused={paused}
volume={volume}
rate={playbackSpeed}
resizeMode={resizeMode === 'none' ? 'contain' : resizeMode}
style={localStyles.player}
onLoad={handleLoad}
onProgress={handleProgress}
onEnd={handleEnd}
onError={handleError}
onTracksChanged={onTracksChanged}
useHardwareDecoding={useHardwareDecoding}
/>
{/* Gesture overlay - transparent, on top of the player */}
<PinchGestureHandler
ref={pinchRef}
onGestureEvent={onPinchGestureEvent}
onHandlerStateChange={onPinchHandlerStateChange}
>
<View style={localStyles.gestureOverlay} pointerEvents="box-only">
<TouchableWithoutFeedback onPress={toggleControls}>
<View style={localStyles.touchArea} />
</TouchableWithoutFeedback>
</View>
</PinchGestureHandler>
</View>
);
};
const localStyles = StyleSheet.create({
player: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
gestureOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
touchArea: {
flex: 1,
backgroundColor: 'transparent',
},
});

View file

@ -0,0 +1,59 @@
import { useMemo } from 'react';
import { logger } from '../../../../utils/logger';
export const useNextEpisode = (
type: string | undefined,
season: number | undefined,
episode: number | undefined,
groupedEpisodes: any,
metadataGroupedEpisodes: any,
episodeId: string | undefined
) => {
// Current description
const currentEpisodeDescription = useMemo(() => {
try {
if ((type as any) !== 'series') return '';
const allEpisodes = Object.values(groupedEpisodes || {}).flat() as any[];
if (!allEpisodes || allEpisodes.length === 0) return '';
let match: any | null = null;
if (episodeId) {
match = allEpisodes.find(ep => ep?.stremioId === episodeId || String(ep?.id) === String(episodeId));
}
if (!match && season && episode) {
match = allEpisodes.find(ep => ep?.season_number === season && ep?.episode_number === episode);
}
return (match?.overview || '').trim();
} catch {
return '';
}
}, [type, groupedEpisodes, episodeId, season, episode]);
// Next Episode
const nextEpisode = useMemo(() => {
try {
if ((type as any) !== 'series' || !season || !episode) return null;
const sourceGroups = groupedEpisodes && Object.keys(groupedEpisodes || {}).length > 0
? groupedEpisodes
: (metadataGroupedEpisodes || {});
const allEpisodes = Object.values(sourceGroups || {}).flat() as any[];
if (!allEpisodes || allEpisodes.length === 0) return null;
let nextEp = allEpisodes.find((ep: any) =>
ep.season_number === season && ep.episode_number === episode + 1
);
if (!nextEp) {
nextEp = allEpisodes.find((ep: any) =>
ep.season_number === season + 1 && ep.episode_number === 1
);
}
return nextEp;
} catch {
return null;
}
}, [type, season, episode, groupedEpisodes, metadataGroupedEpisodes]);
return { currentEpisodeDescription, nextEpisode };
};

View file

@ -0,0 +1,149 @@
import { useRef, useState, useEffect } from 'react';
import { Animated, InteractionManager } from 'react-native';
import FastImage from '@d11/react-native-fast-image';
import { logger } from '../../../../utils/logger';
export const useOpeningAnimation = (backdrop: string | undefined, metadata: any) => {
// Animation Values
const fadeAnim = useRef(new Animated.Value(1)).current;
const openingFadeAnim = useRef(new Animated.Value(0)).current;
const openingScaleAnim = useRef(new Animated.Value(0.8)).current;
const backgroundFadeAnim = useRef(new Animated.Value(1)).current;
const backdropImageOpacityAnim = useRef(new Animated.Value(0)).current;
const logoScaleAnim = useRef(new Animated.Value(0.8)).current;
const logoOpacityAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current;
const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false);
const [shouldHideOpeningOverlay, setShouldHideOpeningOverlay] = useState(false);
const [isBackdropLoaded, setIsBackdropLoaded] = useState(false);
// Prefetch Background
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
if (backdrop && typeof backdrop === 'string') {
setIsBackdropLoaded(false);
backdropImageOpacityAnim.setValue(0);
try {
FastImage.preload([{ uri: backdrop }]);
setIsBackdropLoaded(true);
Animated.timing(backdropImageOpacityAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}).start();
} catch (error) {
setIsBackdropLoaded(true);
backdropImageOpacityAnim.setValue(1);
}
} else {
setIsBackdropLoaded(true);
backdropImageOpacityAnim.setValue(0);
}
});
return () => task.cancel();
}, [backdrop]);
// Prefetch Logo
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
const logoUrl = metadata?.logo;
if (logoUrl && typeof logoUrl === 'string') {
try {
FastImage.preload([{ uri: logoUrl }]);
} catch (error) { }
}
});
return () => task.cancel();
}, [metadata]);
const startOpeningAnimation = () => {
Animated.parallel([
Animated.timing(logoOpacityAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.spring(logoScaleAnim, {
toValue: 1,
tension: 80,
friction: 8,
useNativeDriver: true,
}),
]).start();
const createPulseAnimation = () => {
return Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
]);
};
const loopPulse = () => {
createPulseAnimation().start(() => {
if (!isOpeningAnimationComplete) {
loopPulse();
}
});
};
loopPulse();
};
const completeOpeningAnimation = () => {
pulseAnim.stopAnimation();
Animated.parallel([
Animated.timing(openingFadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(openingScaleAnim, {
toValue: 1,
duration: 350,
useNativeDriver: true,
}),
Animated.timing(backgroundFadeAnim, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}),
]).start(() => {
setIsOpeningAnimationComplete(true);
setTimeout(() => {
setShouldHideOpeningOverlay(true);
}, 450);
});
setTimeout(() => {
if (!isOpeningAnimationComplete) {
// logger.warn('[AndroidVideoPlayer] Opening animation fallback triggered');
setIsOpeningAnimationComplete(true);
}
}, 1000);
};
return {
fadeAnim,
openingFadeAnim,
openingScaleAnim,
backgroundFadeAnim,
backdropImageOpacityAnim,
logoScaleAnim,
logoOpacityAnim,
pulseAnim,
isOpeningAnimationComplete,
shouldHideOpeningOverlay,
isBackdropLoaded,
startOpeningAnimation,
completeOpeningAnimation
};
};

View file

@ -0,0 +1,67 @@
import { useRef, useCallback } from 'react';
import { Platform } from 'react-native';
import { logger } from '../../../../utils/logger';
const DEBUG_MODE = true; // Temporarily enable for debugging seek
const END_EPSILON = 0.3;
export const usePlayerControls = (
mpvPlayerRef: any,
paused: boolean,
setPaused: (paused: boolean) => void,
currentTime: number,
duration: number,
isSeeking: React.MutableRefObject<boolean>,
isMounted: React.MutableRefObject<boolean>
) => {
// iOS seeking helpers
const iosWasPausedDuringSeekRef = useRef<boolean | null>(null);
const togglePlayback = useCallback(() => {
setPaused(!paused);
}, [paused, setPaused]);
const seekToTime = useCallback((rawSeconds: number) => {
const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds));
console.log('[usePlayerControls] seekToTime called:', {
rawSeconds,
timeInSeconds,
hasMpvRef: !!mpvPlayerRef?.current,
duration,
isSeeking: isSeeking.current
});
// MPV Player
if (mpvPlayerRef.current && duration > 0) {
console.log(`[usePlayerControls][MPV] Seeking to ${timeInSeconds}`);
isSeeking.current = true;
mpvPlayerRef.current.seek(timeInSeconds);
// Reset seeking flag after a delay
setTimeout(() => {
if (isMounted.current) {
isSeeking.current = false;
}
}, 500);
} else {
console.log('[usePlayerControls][MPV] Cannot seek - ref or duration invalid:', {
hasRef: !!mpvPlayerRef?.current,
duration
});
}
}, [duration, paused, setPaused, mpvPlayerRef, isSeeking, isMounted]);
const skip = useCallback((seconds: number) => {
console.log('[usePlayerControls] skip called:', { seconds, currentTime, newTime: currentTime + seconds });
seekToTime(currentTime + seconds);
}, [currentTime, seekToTime]);
return {
togglePlayback,
seekToTime,
skip,
iosWasPausedDuringSeekRef
};
};

View file

@ -0,0 +1,28 @@
import { useState } from 'react';
import { Episode } from '../../../../types/metadata';
export const usePlayerModals = () => {
const [showAudioModal, setShowAudioModal] = useState(false);
const [showSubtitleModal, setShowSubtitleModal] = useState(false);
const [showSpeedModal, setShowSpeedModal] = useState(false);
const [showSourcesModal, setShowSourcesModal] = useState(false);
const [showEpisodesModal, setShowEpisodesModal] = useState(false);
const [showEpisodeStreamsModal, setShowEpisodeStreamsModal] = useState(false);
const [showErrorModal, setShowErrorModal] = useState(false);
// Some modals have associated data
const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState<Episode | null>(null);
const [errorDetails, setErrorDetails] = useState<string>('');
return {
showAudioModal, setShowAudioModal,
showSubtitleModal, setShowSubtitleModal,
showSpeedModal, setShowSpeedModal,
showSourcesModal, setShowSourcesModal,
showEpisodesModal, setShowEpisodesModal,
showEpisodeStreamsModal, setShowEpisodeStreamsModal,
showErrorModal, setShowErrorModal,
selectedEpisodeForStreams, setSelectedEpisodeForStreams,
errorDetails, setErrorDetails
};
};

View file

@ -0,0 +1,123 @@
import { useEffect, useRef } from 'react';
import { StatusBar, Platform, Dimensions, AppState } from 'react-native';
import RNImmersiveMode from 'react-native-immersive-mode';
import * as NavigationBar from 'expo-navigation-bar';
import * as Brightness from 'expo-brightness';
import { logger } from '../../../../utils/logger';
import { useFocusEffect } from '@react-navigation/native';
import { useCallback } from 'react';
const DEBUG_MODE = false;
export const usePlayerSetup = (
setScreenDimensions: (dim: any) => void,
setVolume: (vol: number) => void,
setBrightness: (bri: number) => void,
paused: boolean
) => {
const originalSystemBrightnessRef = useRef<number | null>(null);
const originalSystemBrightnessModeRef = useRef<number | null>(null);
const isAppBackgrounded = useRef(false);
const enableImmersiveMode = async () => {
if (Platform.OS === 'android') {
// Standard immersive mode
RNImmersiveMode.setBarTranslucent(true);
RNImmersiveMode.fullLayout(true);
StatusBar.setHidden(true, 'none');
// Explicitly hide bottom navigation bar using Expo
try {
await NavigationBar.setVisibilityAsync("hidden");
await NavigationBar.setBehaviorAsync("overlay-swipe");
} catch (e) {
// Ignore errors on non-supported devices
}
}
};
const disableImmersiveMode = async () => {
if (Platform.OS === 'android') {
RNImmersiveMode.setBarTranslucent(false);
RNImmersiveMode.fullLayout(false);
StatusBar.setHidden(false, 'fade');
try {
await NavigationBar.setVisibilityAsync("visible");
} catch (e) {
// Ignore
}
}
};
useFocusEffect(
useCallback(() => {
enableImmersiveMode();
return () => { };
}, [])
);
useEffect(() => {
// Initial Setup
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
setScreenDimensions(screen);
enableImmersiveMode();
});
StatusBar.setHidden(true, 'none');
enableImmersiveMode();
// Initialize volume (default to 1.0)
setVolume(1.0);
// Initialize Brightness
const initBrightness = async () => {
try {
if (Platform.OS === 'android') {
try {
const [sysBright, sysMode] = await Promise.all([
(Brightness as any).getSystemBrightnessAsync?.(),
(Brightness as any).getSystemBrightnessModeAsync?.()
]);
originalSystemBrightnessRef.current = typeof sysBright === 'number' ? sysBright : null;
originalSystemBrightnessModeRef.current = typeof sysMode === 'number' ? sysMode : null;
} catch (e) {
// ignore
}
}
const currentBrightness = await Brightness.getBrightnessAsync();
setBrightness(currentBrightness);
} catch (error) {
logger.warn('[usePlayerSetup] Error setting brightness', error);
setBrightness(1.0);
}
};
initBrightness();
return () => {
subscription?.remove();
disableImmersiveMode();
// Restore brightness on unmount
if (Platform.OS === 'android' && originalSystemBrightnessRef.current !== null) {
// restoration logic normally happens here or in a separate effect
}
};
}, []);
// Handle App State
useEffect(() => {
const onAppStateChange = (state: string) => {
if (state === 'active') {
isAppBackgrounded.current = false;
enableImmersiveMode();
} else if (state === 'background' || state === 'inactive') {
isAppBackgrounded.current = true;
}
};
const sub = AppState.addEventListener('change', onAppStateChange);
return () => sub.remove();
}, []);
return { isAppBackgrounded };
};

View file

@ -0,0 +1,41 @@
import { useState, useRef } from 'react';
import { Dimensions } from 'react-native';
import { ResizeModeType, SelectedTrack } from '../../utils/playerTypes';
export const usePlayerState = () => {
const [paused, setPaused] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState(0);
// UI State
const [showControls, setShowControls] = useState(true);
const [resizeMode, setResizeMode] = useState<ResizeModeType>('contain');
const [isBuffering, setIsBuffering] = useState(false);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
// Layout State
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
const [screenDimensions, setScreenDimensions] = useState(Dimensions.get('screen'));
// Logic State
const isSeeking = useRef(false);
const isDragging = useRef(false);
const isMounted = useRef(true);
return {
paused, setPaused,
currentTime, setCurrentTime,
duration, setDuration,
buffered, setBuffered,
showControls, setShowControls,
resizeMode, setResizeMode,
isBuffering, setIsBuffering,
isVideoLoaded, setIsVideoLoaded,
videoAspectRatio, setVideoAspectRatio,
screenDimensions, setScreenDimensions,
isSeeking,
isDragging,
isMounted,
};
};

View file

@ -0,0 +1,43 @@
import { useState, useMemo } from 'react';
import { SelectedTrack, TextTrack, AudioTrack } from '../../utils/playerTypes';
interface Track {
id: number;
name: string;
language?: string;
}
export const usePlayerTracks = () => {
// Tracks from native player (MPV/RN-Video)
const [rnVideoAudioTracks, setRnVideoAudioTracks] = useState<Track[]>([]);
const [rnVideoTextTracks, setRnVideoTextTracks] = useState<Track[]>([]);
// Selected Tracks State
const [selectedAudioTrack, setSelectedAudioTrack] = useState<SelectedTrack | null>({ type: 'system' });
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
// Unified Tracks (now just returns native tracks)
const ksAudioTracks = useMemo(() => rnVideoAudioTracks, [rnVideoAudioTracks]);
const ksTextTracks = useMemo(() => rnVideoTextTracks, [rnVideoTextTracks]);
// Unified Selection
const computedSelectedAudioTrack = useMemo(() =>
selectedAudioTrack?.type === 'index' && selectedAudioTrack?.value !== undefined
? Number(selectedAudioTrack?.value)
: null,
[selectedAudioTrack]
);
const computedSelectedTextTrack = useMemo(() => selectedTextTrack, [selectedTextTrack]);
return {
rnVideoAudioTracks, setRnVideoAudioTracks,
rnVideoTextTracks, setRnVideoTextTracks,
selectedAudioTrack, setSelectedAudioTrack,
selectedTextTrack, setSelectedTextTrack,
ksAudioTracks,
ksTextTracks,
computedSelectedAudioTrack,
computedSelectedTextTrack
};
};

View file

@ -0,0 +1,93 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Animated } from 'react-native';
import { mmkvStorage } from '../../../../services/mmkvStorage';
import { logger } from '../../../../utils/logger';
const SPEED_SETTINGS_KEY = '@nuvio_speed_settings';
export const useSpeedControl = (initialSpeed: number = 1.0) => {
const [playbackSpeed, setPlaybackSpeed] = useState<number>(initialSpeed);
const [holdToSpeedEnabled, setHoldToSpeedEnabled] = useState(true);
const [holdToSpeedValue, setHoldToSpeedValue] = useState(2.0);
const [isSpeedBoosted, setIsSpeedBoosted] = useState(false);
const [originalSpeed, setOriginalSpeed] = useState<number>(initialSpeed);
const [showSpeedActivatedOverlay, setShowSpeedActivatedOverlay] = useState(false);
const speedActivatedOverlayOpacity = useRef(new Animated.Value(0)).current;
// Load Settings
useEffect(() => {
const loadSettings = async () => {
try {
const saved = await mmkvStorage.getItem(SPEED_SETTINGS_KEY);
if (saved) {
const settings = JSON.parse(saved);
if (typeof settings.holdToSpeedEnabled === 'boolean') setHoldToSpeedEnabled(settings.holdToSpeedEnabled);
if (typeof settings.holdToSpeedValue === 'number') setHoldToSpeedValue(settings.holdToSpeedValue);
}
} catch (e) {
logger.warn('[useSpeedControl] Error loading settings', e);
}
};
loadSettings();
}, []);
// Save Settings
useEffect(() => {
const saveSettings = async () => {
try {
await mmkvStorage.setItem(SPEED_SETTINGS_KEY, JSON.stringify({
holdToSpeedEnabled,
holdToSpeedValue
}));
} catch (e) { }
};
saveSettings();
}, [holdToSpeedEnabled, holdToSpeedValue]);
const activateSpeedBoost = useCallback(() => {
if (!holdToSpeedEnabled || isSpeedBoosted || playbackSpeed === holdToSpeedValue) return;
setOriginalSpeed(playbackSpeed);
setPlaybackSpeed(holdToSpeedValue);
setIsSpeedBoosted(true);
setShowSpeedActivatedOverlay(true);
Animated.timing(speedActivatedOverlayOpacity, {
toValue: 1,
duration: 200,
useNativeDriver: true
}).start();
setTimeout(() => {
Animated.timing(speedActivatedOverlayOpacity, {
toValue: 0,
duration: 300,
useNativeDriver: true
}).start(() => setShowSpeedActivatedOverlay(false));
}, 2000);
}, [holdToSpeedEnabled, isSpeedBoosted, playbackSpeed, holdToSpeedValue]);
const deactivateSpeedBoost = useCallback(() => {
if (isSpeedBoosted) {
setPlaybackSpeed(originalSpeed);
setIsSpeedBoosted(false);
Animated.timing(speedActivatedOverlayOpacity, { toValue: 0, duration: 100, useNativeDriver: true }).start();
}
}, [isSpeedBoosted, originalSpeed]);
return {
playbackSpeed,
setPlaybackSpeed,
holdToSpeedEnabled,
setHoldToSpeedEnabled,
holdToSpeedValue,
setHoldToSpeedValue,
isSpeedBoosted,
activateSpeedBoost,
deactivateSpeedBoost,
showSpeedActivatedOverlay,
speedActivatedOverlayOpacity
};
};

View file

@ -0,0 +1,125 @@
import { useState, useEffect, useRef } from 'react';
import { storageService } from '../../../../services/storageService';
import { logger } from '../../../../utils/logger';
import { useSettings } from '../../../../hooks/useSettings';
export const useWatchProgress = (
id: string | undefined,
type: string | undefined,
episodeId: string | undefined,
currentTime: number,
duration: number,
paused: boolean,
traktAutosync: any,
seekToTime: (time: number) => void
) => {
const [resumePosition, setResumePosition] = useState<number | null>(null);
const [savedDuration, setSavedDuration] = useState<number | null>(null);
const [initialPosition, setInitialPosition] = useState<number | null>(null);
const [showResumeOverlay, setShowResumeOverlay] = useState(false);
const [progressSaveInterval, setProgressSaveInterval] = useState<NodeJS.Timeout | null>(null);
const { settings: appSettings } = useSettings();
const initialSeekTargetRef = useRef<number | null>(null);
// Values refs for unmount cleanup
const currentTimeRef = useRef(currentTime);
const durationRef = useRef(duration);
useEffect(() => {
currentTimeRef.current = currentTime;
}, [currentTime]);
useEffect(() => {
durationRef.current = duration;
}, [duration]);
// Load Watch Progress
useEffect(() => {
const loadWatchProgress = async () => {
if (id && type) {
try {
const savedProgress = await storageService.getWatchProgress(id, type, episodeId);
console.log('[useWatchProgress] Loaded saved progress:', savedProgress);
if (savedProgress) {
const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100;
console.log('[useWatchProgress] Progress percent:', progressPercent);
if (progressPercent < 85) {
setResumePosition(savedProgress.currentTime);
setSavedDuration(savedProgress.duration);
if (appSettings.alwaysResume) {
console.log('[useWatchProgress] Always resume enabled, setting initial position:', savedProgress.currentTime);
setInitialPosition(savedProgress.currentTime);
initialSeekTargetRef.current = savedProgress.currentTime;
// Don't call seekToTime here - duration is 0
// The seek will be handled in handleLoad callback
} else {
setShowResumeOverlay(true);
}
}
}
} catch (error) {
logger.error('[useWatchProgress] Error loading watch progress:', error);
}
}
};
loadWatchProgress();
}, [id, type, episodeId, appSettings.alwaysResume]);
const saveWatchProgress = async () => {
if (id && type && currentTimeRef.current > 0 && durationRef.current > 0) {
const progress = {
currentTime: currentTimeRef.current,
duration: durationRef.current,
lastUpdated: Date.now()
};
try {
await storageService.setWatchProgress(id, type, progress, episodeId);
await traktAutosync.handleProgressUpdate(currentTimeRef.current, durationRef.current);
} catch (error) {
logger.error('[useWatchProgress] Error saving watch progress:', error);
}
}
};
// Save Interval
useEffect(() => {
if (id && type && !paused && duration > 0) {
if (progressSaveInterval) clearInterval(progressSaveInterval);
const interval = setInterval(() => {
saveWatchProgress();
}, 10000);
setProgressSaveInterval(interval);
return () => {
clearInterval(interval);
setProgressSaveInterval(null);
};
}
}, [id, type, paused, currentTime, duration]);
// Unmount Save
useEffect(() => {
return () => {
if (id && type && durationRef.current > 0) {
saveWatchProgress();
traktAutosync.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'unmount');
}
};
}, [id, type]);
return {
resumePosition,
savedDuration,
initialPosition,
setInitialPosition,
showResumeOverlay,
setShowResumeOverlay,
saveWatchProgress,
initialSeekTargetRef
};
};

View file

@ -0,0 +1,199 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import {
TapGestureHandler,
PanGestureHandler,
LongPressGestureHandler,
State
} from 'react-native-gesture-handler';
import { MaterialIcons } from '@expo/vector-icons';
import { styles as localStyles } from '../utils/playerStyles';
interface GestureControlsProps {
screenDimensions: { width: number, height: number };
gestureControls: any;
onLongPressActivated: () => void;
onLongPressEnd: () => void;
onLongPressStateChange: (event: any) => void;
toggleControls: () => void;
showControls: boolean;
hideControls: () => void;
volume: number;
brightness: number;
controlsTimeout: React.MutableRefObject<NodeJS.Timeout | null>;
}
export const GestureControls: React.FC<GestureControlsProps> = ({
screenDimensions,
gestureControls,
onLongPressActivated,
onLongPressEnd,
onLongPressStateChange,
toggleControls,
showControls,
hideControls,
volume,
brightness,
controlsTimeout
}) => {
const getVolumeIcon = (value: number) => {
if (value === 0) return 'volume-off';
if (value < 0.3) return 'volume-mute';
if (value < 0.6) return 'volume-down';
return 'volume-up';
};
const getBrightnessIcon = (value: number) => {
if (value < 0.3) return 'brightness-low';
if (value < 0.7) return 'brightness-medium';
return 'brightness-high';
};
return (
<>
{/* Left side gesture handler - brightness + tap + long press */}
<LongPressGestureHandler
onActivated={onLongPressActivated}
onEnded={onLongPressEnd}
onHandlerStateChange={onLongPressStateChange}
minDurationMs={500}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<PanGestureHandler
onGestureEvent={gestureControls.onBrightnessGestureEvent}
activeOffsetY={[-10, 10]}
failOffsetX={[-30, 30]}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
maxPointers={1}
>
<TapGestureHandler
onActivated={toggleControls}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15,
left: 0,
width: screenDimensions.width * 0.4,
height: screenDimensions.height * 0.7,
zIndex: 10,
}} />
</TapGestureHandler>
</PanGestureHandler>
</LongPressGestureHandler>
{/* Right side gesture handler - volume + tap + long press */}
<LongPressGestureHandler
onActivated={onLongPressActivated}
onEnded={onLongPressEnd}
onHandlerStateChange={onLongPressStateChange}
minDurationMs={500}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<PanGestureHandler
onGestureEvent={gestureControls.onVolumeGestureEvent}
activeOffsetY={[-10, 10]}
failOffsetX={[-30, 30]}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
maxPointers={1}
>
<TapGestureHandler
onActivated={toggleControls}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15,
right: 0,
width: screenDimensions.width * 0.4,
height: screenDimensions.height * 0.7,
zIndex: 10,
}} />
</TapGestureHandler>
</PanGestureHandler>
</LongPressGestureHandler>
{/* Center area tap handler */}
<TapGestureHandler
onActivated={() => {
if (showControls) {
const timeoutId = setTimeout(() => {
hideControls();
}, 0);
if (controlsTimeout.current) {
clearTimeout(controlsTimeout.current);
}
controlsTimeout.current = timeoutId;
} else {
toggleControls();
}
}}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15,
left: screenDimensions.width * 0.4,
width: screenDimensions.width * 0.2,
height: screenDimensions.height * 0.7,
zIndex: 5,
}} />
</TapGestureHandler>
{/* Volume/Brightness Pill Overlay - Compact top design */}
{(gestureControls.showVolumeOverlay || gestureControls.showBrightnessOverlay) && (
<View style={localStyles.gestureIndicatorContainer}>
<View style={[
localStyles.gestureIndicatorPill,
gestureControls.showVolumeOverlay && volume === 0 && {
backgroundColor: 'rgba(96, 20, 16, 0.85)'
}
]}>
<View
style={[
localStyles.iconWrapper,
gestureControls.showVolumeOverlay && volume === 0 && {
backgroundColor: 'rgba(242, 184, 181, 0.3)'
}
]}
>
<MaterialIcons
name={
gestureControls.showVolumeOverlay
? getVolumeIcon(volume)
: getBrightnessIcon(brightness)
}
size={18}
color={
gestureControls.showVolumeOverlay && volume === 0
? 'rgba(242, 184, 181, 1)'
: 'rgba(255, 255, 255, 0.9)'
}
/>
</View>
<Text
style={[
localStyles.gestureText,
gestureControls.showVolumeOverlay && volume === 0 && { color: 'rgba(242, 184, 181, 1)' }
]}
>
{gestureControls.showVolumeOverlay && volume === 0
? "Muted"
: `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%`
}
</Text>
</View>
</View>
)}
</>
);
};

View file

@ -0,0 +1,259 @@
import React, { useState, useRef, useEffect } from 'react';
import { View, Text, TouchableOpacity, ScrollView, Animated, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// Delay before showing pause overlay (in milliseconds)
const PAUSE_OVERLAY_DELAY = 5000;
interface PauseOverlayProps {
visible: boolean;
onClose: () => void;
title: string;
episodeTitle?: string;
season?: number;
episode?: number;
year?: string | number;
type: string;
description: string;
cast: any[];
screenDimensions: { width: number, height: number };
}
export const PauseOverlay: React.FC<PauseOverlayProps> = ({
visible,
onClose,
title,
episodeTitle,
season,
episode,
year,
type,
description,
cast,
screenDimensions
}) => {
const insets = useSafeAreaInsets();
// Internal state to track if overlay should actually be shown (after delay)
const [shouldShow, setShouldShow] = useState(false);
const delayTimerRef = useRef<NodeJS.Timeout | null>(null);
// Handle delay logic - show overlay only after paused for 5 seconds
useEffect(() => {
if (visible) {
// Start timer to show overlay after delay
delayTimerRef.current = setTimeout(() => {
setShouldShow(true);
}, PAUSE_OVERLAY_DELAY);
} else {
// Immediately hide when not paused
if (delayTimerRef.current) {
clearTimeout(delayTimerRef.current);
delayTimerRef.current = null;
}
setShouldShow(false);
}
return () => {
if (delayTimerRef.current) {
clearTimeout(delayTimerRef.current);
delayTimerRef.current = null;
}
};
}, [visible]);
// Internal Animation State
const pauseOverlayOpacity = useRef(new Animated.Value(shouldShow ? 1 : 0)).current;
const pauseOverlayTranslateY = useRef(new Animated.Value(12)).current;
const metadataOpacity = useRef(new Animated.Value(1)).current;
const metadataScale = useRef(new Animated.Value(1)).current;
// Cast Details State
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
const [showCastDetails, setShowCastDetails] = useState(false);
const castDetailsOpacity = useRef(new Animated.Value(0)).current;
const castDetailsScale = useRef(new Animated.Value(0.95)).current;
useEffect(() => {
Animated.timing(pauseOverlayOpacity, {
toValue: shouldShow ? 1 : 0,
duration: 250,
useNativeDriver: true
}).start();
}, [shouldShow]);
if (!shouldShow && !showCastDetails) return null;
return (
<TouchableOpacity
activeOpacity={1}
onPress={onClose}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 30,
}}
>
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: pauseOverlayOpacity,
}}
>
{/* Horizontal Fade */}
<View style={{ position: 'absolute', top: 0, left: 0, bottom: 0, width: screenDimensions.width * 0.7 }}>
<LinearGradient
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
colors={['rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)']}
locations={[0, 1]}
style={StyleSheet.absoluteFill}
/>
</View>
<LinearGradient
colors={[
'rgba(0,0,0,0.6)',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.2)',
'rgba(0,0,0,0.0)'
]}
locations={[0, 0.3, 0.6, 1]}
style={StyleSheet.absoluteFill}
/>
<Animated.View style={{
position: 'absolute',
left: 24 + insets.left,
right: 24 + insets.right,
top: 24 + insets.top,
bottom: 110 + insets.bottom,
transform: [{ translateY: pauseOverlayTranslateY }]
}}>
{showCastDetails && selectedCastMember ? (
<Animated.View
style={{
flex: 1,
justifyContent: 'center',
opacity: castDetailsOpacity,
transform: [{ scale: castDetailsScale }]
}}
>
<View style={{ alignItems: 'flex-start', paddingBottom: screenDimensions.height * 0.1 }}>
<TouchableOpacity
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 24, paddingVertical: 8, paddingHorizontal: 4 }}
onPress={() => {
Animated.parallel([
Animated.timing(castDetailsOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(castDetailsScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
]).start(() => {
setShowCastDetails(false);
setSelectedCastMember(null);
Animated.parallel([
Animated.timing(metadataOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(metadataScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
]).start();
});
}}
>
<MaterialIcons name="arrow-back" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
<Text style={{ color: '#B8B8B8', fontSize: Math.min(14, screenDimensions.width * 0.02) }}>Back to details</Text>
</TouchableOpacity>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', width: '100%' }}>
{selectedCastMember.profile_path && (
<View style={{ marginRight: 20, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 5 }}>
<FastImage
source={{ uri: `https://image.tmdb.org/t/p/w300${selectedCastMember.profile_path}` }}
style={{ width: Math.min(120, screenDimensions.width * 0.18), height: Math.min(180, screenDimensions.width * 0.27), borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.1)' }}
resizeMode={FastImage.resizeMode.cover}
/>
</View>
)}
<View style={{ flex: 1, paddingTop: 8 }}>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(32, screenDimensions.width * 0.045), fontWeight: '800', marginBottom: 8 }} numberOfLines={2}>
{selectedCastMember.name}
</Text>
{selectedCastMember.character && (
<Text style={{ color: '#CCCCCC', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8, fontWeight: '500', fontStyle: 'italic' }} numberOfLines={2}>
as {selectedCastMember.character}
</Text>
)}
{selectedCastMember.biography && (
<Text style={{ color: '#D6D6D6', fontSize: Math.min(14, screenDimensions.width * 0.019), lineHeight: Math.min(20, screenDimensions.width * 0.026), marginTop: 16, opacity: 0.9 }} numberOfLines={4}>
{selectedCastMember.biography}
</Text>
)}
</View>
</View>
</View>
</Animated.View>
) : (
<Animated.View style={{ flex: 1, justifyContent: 'space-between', opacity: metadataOpacity, transform: [{ scale: metadataScale }] }}>
<View>
<Text style={{ color: '#B8B8B8', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }}>You're watching</Text>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(48, screenDimensions.width * 0.06), fontWeight: '800', marginBottom: 10 }} numberOfLines={2}>
{title}
</Text>
{!!year && (
<Text style={{ color: '#CCCCCC', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }} numberOfLines={1}>
{`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`}
</Text>
)}
{!!episodeTitle && (
<Text style={{ color: '#FFFFFF', fontSize: Math.min(20, screenDimensions.width * 0.03), fontWeight: '600', marginBottom: 8 }} numberOfLines={2}>
{episodeTitle}
</Text>
)}
{description && (
<Text style={{ color: '#D6D6D6', fontSize: Math.min(18, screenDimensions.width * 0.025), lineHeight: Math.min(24, screenDimensions.width * 0.03) }} numberOfLines={3}>
{description}
</Text>
)}
{cast && cast.length > 0 && (
<View style={{ marginTop: 16 }}>
<Text style={{ color: '#B8B8B8', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8 }}>Cast</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{cast.slice(0, 6).map((castMember: any, index: number) => (
<TouchableOpacity
key={castMember.id || index}
style={{ backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: Math.min(12, screenDimensions.width * 0.015), paddingVertical: Math.min(6, screenDimensions.height * 0.008), marginRight: 8, marginBottom: 8 }}
onPress={() => {
setSelectedCastMember(castMember);
Animated.parallel([
Animated.timing(metadataOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(metadataScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
]).start(() => {
setShowCastDetails(true);
Animated.parallel([
Animated.timing(castDetailsOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(castDetailsScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
]).start();
});
}}
>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(14, screenDimensions.width * 0.018) }}>
{castMember.name}
</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
</View>
</Animated.View>
)}
</Animated.View>
</Animated.View>
</TouchableOpacity>
);
};

View file

@ -0,0 +1,38 @@
/**
* Shared Speed Activated Overlay Component
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import React from 'react';
import { View, Text, Animated } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { styles } from '../utils/playerStyles';
interface SpeedActivatedOverlayProps {
visible: boolean;
opacity: Animated.Value;
speed: number;
}
export const SpeedActivatedOverlay: React.FC<SpeedActivatedOverlayProps> = ({
visible,
opacity,
speed
}) => {
if (!visible) return null;
return (
<Animated.View
style={[
styles.speedActivatedOverlay,
{ opacity: opacity }
]}
>
<View style={styles.speedActivatedContainer}>
<MaterialIcons name="fast-forward" size={32} color="#FFFFFF" />
<Text style={styles.speedActivatedText}>{speed}x Speed</Text>
</View>
</Animated.View>
);
};
export default SpeedActivatedOverlay;

View file

@ -0,0 +1,8 @@
/**
* Shared Player Components
* Export all reusable components for both Android and iOS players
*/
export { SpeedActivatedOverlay } from './SpeedActivatedOverlay';
export { PauseOverlay } from './PauseOverlay';
export { GestureControls } from './GestureControls';

View file

@ -0,0 +1,21 @@
/**
* Shared Player Hooks
* Export all reusable hooks for both Android and iOS players
*/
// State Management
export { usePlayerState, type PlayerResizeMode } from './usePlayerState';
export { usePlayerModals } from './usePlayerModals';
export { usePlayerTracks } from './usePlayerTracks';
export { useCustomSubtitles } from './useCustomSubtitles';
// Controls & Playback
export { usePlayerControls } from './usePlayerControls';
export { useSpeedControl } from './useSpeedControl';
// Animation & UI
export { useOpeningAnimation } from './useOpeningAnimation';
export { usePlayerSetup } from './usePlayerSetup';
// Content
export { useNextEpisode } from './useNextEpisode';

View file

@ -0,0 +1,110 @@
/**
* Shared Custom Subtitles Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useState, useEffect } from 'react';
import {
DEFAULT_SUBTITLE_SIZE,
SubtitleCue,
SubtitleSegment,
WyzieSubtitle
} from '../utils/playerTypes';
import { storageService } from '../../../services/storageService';
export const useCustomSubtitles = () => {
// Data State
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
const [currentFormattedSegments, setCurrentFormattedSegments] = useState<SubtitleSegment[][]>([]);
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
// Loading State
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState<boolean>(false);
// Styling State
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(false);
const [subtitleTextColor, setSubtitleTextColor] = useState<string>('#FFFFFF');
const [subtitleBgOpacity, setSubtitleBgOpacity] = useState<number>(0.7);
const [subtitleTextShadow, setSubtitleTextShadow] = useState<boolean>(true);
const [subtitleOutline, setSubtitleOutline] = useState<boolean>(true);
const [subtitleOutlineColor, setSubtitleOutlineColor] = useState<string>('#000000');
const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState<number>(4);
const [subtitleAlign, setSubtitleAlign] = useState<'center' | 'left' | 'right'>('center');
const [subtitleBottomOffset, setSubtitleBottomOffset] = useState<number>(20);
const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState<number>(0);
const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState<number>(1.2);
const [subtitleOffsetSec, setSubtitleOffsetSec] = useState<number>(0);
// Load subtitle settings on mount
useEffect(() => {
const loadSettings = async () => {
const settings = await storageService.getSubtitleSettings();
if (settings) {
if (settings.subtitleSize !== undefined) setSubtitleSize(settings.subtitleSize);
if (settings.subtitleBackground !== undefined) setSubtitleBackground(settings.subtitleBackground);
if (settings.subtitleTextColor !== undefined) setSubtitleTextColor(settings.subtitleTextColor);
if (settings.subtitleBgOpacity !== undefined) setSubtitleBgOpacity(settings.subtitleBgOpacity);
if (settings.subtitleTextShadow !== undefined) setSubtitleTextShadow(settings.subtitleTextShadow);
if (settings.subtitleOutline !== undefined) setSubtitleOutline(settings.subtitleOutline);
if (settings.subtitleOutlineColor !== undefined) setSubtitleOutlineColor(settings.subtitleOutlineColor);
if (settings.subtitleOutlineWidth !== undefined) setSubtitleOutlineWidth(settings.subtitleOutlineWidth);
if (settings.subtitleAlign !== undefined) setSubtitleAlign(settings.subtitleAlign);
if (settings.subtitleBottomOffset !== undefined) setSubtitleBottomOffset(settings.subtitleBottomOffset);
if (settings.subtitleLetterSpacing !== undefined) setSubtitleLetterSpacing(settings.subtitleLetterSpacing);
if (settings.subtitleLineHeightMultiplier !== undefined) setSubtitleLineHeightMultiplier(settings.subtitleLineHeightMultiplier);
}
};
loadSettings();
}, []);
// Save subtitle settings when they change
useEffect(() => {
const saveSettings = async () => {
await storageService.saveSubtitleSettings({
subtitleSize,
subtitleBackground,
subtitleTextColor,
subtitleBgOpacity,
subtitleTextShadow,
subtitleOutline,
subtitleOutlineColor,
subtitleOutlineWidth,
subtitleAlign,
subtitleBottomOffset,
subtitleLetterSpacing,
subtitleLineHeightMultiplier,
});
};
saveSettings();
}, [
subtitleSize, subtitleBackground, subtitleTextColor, subtitleBgOpacity,
subtitleTextShadow, subtitleOutline, subtitleOutlineColor, subtitleOutlineWidth,
subtitleAlign, subtitleBottomOffset, subtitleLetterSpacing, subtitleLineHeightMultiplier
]);
return {
customSubtitles, setCustomSubtitles,
currentSubtitle, setCurrentSubtitle,
currentFormattedSegments, setCurrentFormattedSegments,
availableSubtitles, setAvailableSubtitles,
useCustomSubtitles, setUseCustomSubtitles,
isLoadingSubtitles, setIsLoadingSubtitles,
isLoadingSubtitleList, setIsLoadingSubtitleList,
subtitleSize, setSubtitleSize,
subtitleBackground, setSubtitleBackground,
subtitleTextColor, setSubtitleTextColor,
subtitleBgOpacity, setSubtitleBgOpacity,
subtitleTextShadow, setSubtitleTextShadow,
subtitleOutline, setSubtitleOutline,
subtitleOutlineColor, setSubtitleOutlineColor,
subtitleOutlineWidth, setSubtitleOutlineWidth,
subtitleAlign, setSubtitleAlign,
subtitleBottomOffset, setSubtitleBottomOffset,
subtitleLetterSpacing, setSubtitleLetterSpacing,
subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier,
subtitleOffsetSec, setSubtitleOffsetSec
};
};

View file

@ -0,0 +1,65 @@
/**
* Shared Next Episode Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useMemo } from 'react';
interface NextEpisodeConfig {
type: string | undefined;
season: number | undefined;
episode: number | undefined;
groupedEpisodes: Record<string, any[]> | undefined;
episodeId?: string;
}
export const useNextEpisode = (config: NextEpisodeConfig) => {
const { type, season, episode, groupedEpisodes, episodeId } = config;
// Current description
const currentEpisodeDescription = useMemo(() => {
try {
if (type !== 'series') return '';
const allEpisodes = Object.values(groupedEpisodes || {}).flat() as any[];
if (!allEpisodes || allEpisodes.length === 0) return '';
let match: any | null = null;
if (episodeId) {
match = allEpisodes.find(ep => ep?.stremioId === episodeId || String(ep?.id) === String(episodeId));
}
if (!match && season && episode) {
match = allEpisodes.find(ep => ep?.season_number === season && ep?.episode_number === episode);
}
return (match?.overview || '').trim();
} catch {
return '';
}
}, [type, groupedEpisodes, episodeId, season, episode]);
// Next Episode
const nextEpisode = useMemo(() => {
try {
if (type !== 'series' || !season || !episode) return null;
const sourceGroups = groupedEpisodes || {};
const allEpisodes = Object.values(sourceGroups).flat() as any[];
if (!allEpisodes || allEpisodes.length === 0) return null;
// Try to find next episode in same season
let nextEp = allEpisodes.find((ep: any) =>
ep.season_number === season && ep.episode_number === episode + 1
);
// If not found, try first episode of next season
if (!nextEp) {
nextEp = allEpisodes.find((ep: any) =>
ep.season_number === season + 1 && ep.episode_number === 1
);
}
return nextEp;
} catch {
return null;
}
}, [type, season, episode, groupedEpisodes]);
return { currentEpisodeDescription, nextEpisode };
};

View file

@ -0,0 +1,152 @@
/**
* Shared Opening Animation Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useRef, useState, useEffect } from 'react';
import { Animated, InteractionManager } from 'react-native';
import FastImage from '@d11/react-native-fast-image';
import { logger } from '../../../utils/logger';
export const useOpeningAnimation = (backdrop: string | undefined, metadata: any) => {
// Animation Values
const fadeAnim = useRef(new Animated.Value(1)).current;
const openingFadeAnim = useRef(new Animated.Value(0)).current;
const openingScaleAnim = useRef(new Animated.Value(0.8)).current;
const backgroundFadeAnim = useRef(new Animated.Value(1)).current;
const backdropImageOpacityAnim = useRef(new Animated.Value(0)).current;
const logoScaleAnim = useRef(new Animated.Value(0.8)).current;
const logoOpacityAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current;
const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false);
const [shouldHideOpeningOverlay, setShouldHideOpeningOverlay] = useState(false);
const [isBackdropLoaded, setIsBackdropLoaded] = useState(false);
// Prefetch Background
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
if (backdrop && typeof backdrop === 'string') {
setIsBackdropLoaded(false);
backdropImageOpacityAnim.setValue(0);
try {
FastImage.preload([{ uri: backdrop }]);
setIsBackdropLoaded(true);
Animated.timing(backdropImageOpacityAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}).start();
} catch (error) {
setIsBackdropLoaded(true);
backdropImageOpacityAnim.setValue(1);
}
} else {
setIsBackdropLoaded(true);
backdropImageOpacityAnim.setValue(0);
}
});
return () => task.cancel();
}, [backdrop]);
// Prefetch Logo
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
const logoUrl = metadata?.logo;
if (logoUrl && typeof logoUrl === 'string') {
try {
FastImage.preload([{ uri: logoUrl }]);
} catch (error) { }
}
});
return () => task.cancel();
}, [metadata]);
const startOpeningAnimation = () => {
Animated.parallel([
Animated.timing(logoOpacityAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.spring(logoScaleAnim, {
toValue: 1,
tension: 80,
friction: 8,
useNativeDriver: true,
}),
]).start();
const createPulseAnimation = () => {
return Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
]);
};
const loopPulse = () => {
createPulseAnimation().start(() => {
if (!isOpeningAnimationComplete) {
loopPulse();
}
});
};
loopPulse();
};
const completeOpeningAnimation = () => {
pulseAnim.stopAnimation();
Animated.parallel([
Animated.timing(openingFadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(openingScaleAnim, {
toValue: 1,
duration: 350,
useNativeDriver: true,
}),
Animated.timing(backgroundFadeAnim, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}),
]).start(() => {
setIsOpeningAnimationComplete(true);
setTimeout(() => {
setShouldHideOpeningOverlay(true);
}, 450);
});
setTimeout(() => {
if (!isOpeningAnimationComplete) {
setIsOpeningAnimationComplete(true);
}
}, 1000);
};
return {
fadeAnim,
openingFadeAnim,
openingScaleAnim,
backgroundFadeAnim,
backdropImageOpacityAnim,
logoScaleAnim,
logoOpacityAnim,
pulseAnim,
isOpeningAnimationComplete,
shouldHideOpeningOverlay,
isBackdropLoaded,
startOpeningAnimation,
completeOpeningAnimation
};
};

View file

@ -0,0 +1,81 @@
/**
* Shared Player Controls Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useRef, useCallback, MutableRefObject } from 'react';
import { Platform } from 'react-native';
import { logger } from '../../../utils/logger';
const DEBUG_MODE = false;
const END_EPSILON = 0.3;
interface PlayerControlsConfig {
playerRef: MutableRefObject<any>;
paused: boolean;
setPaused: (paused: boolean) => void;
currentTime: number;
duration: number;
isSeeking: MutableRefObject<boolean>;
isMounted: MutableRefObject<boolean>;
}
export const usePlayerControls = (config: PlayerControlsConfig) => {
const {
playerRef,
paused,
setPaused,
currentTime,
duration,
isSeeking,
isMounted
} = config;
// iOS seeking helpers
const iosWasPausedDuringSeekRef = useRef<boolean | null>(null);
const togglePlayback = useCallback(() => {
setPaused(!paused);
}, [paused, setPaused]);
const seekToTime = useCallback((rawSeconds: number) => {
const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds));
if (playerRef.current && duration > 0 && !isSeeking.current) {
if (DEBUG_MODE) logger.log(`[usePlayerControls] Seeking to ${timeInSeconds}`);
isSeeking.current = true;
// iOS optimization: pause while seeking for smoother experience
if (Platform.OS === 'ios') {
iosWasPausedDuringSeekRef.current = paused;
if (!paused) setPaused(true);
}
// Actually perform the seek
playerRef.current.seek(timeInSeconds);
// Debounce the seeking state reset
setTimeout(() => {
if (isMounted.current && isSeeking.current) {
isSeeking.current = false;
// Resume if it was playing (iOS specific)
if (Platform.OS === 'ios' && iosWasPausedDuringSeekRef.current === false) {
setPaused(false);
iosWasPausedDuringSeekRef.current = null;
}
}
}, 500);
}
}, [duration, paused, setPaused, playerRef, isSeeking, isMounted]);
const skip = useCallback((seconds: number) => {
seekToTime(currentTime + seconds);
}, [currentTime, seekToTime]);
return {
togglePlayback,
seekToTime,
skip,
iosWasPausedDuringSeekRef
};
};

View file

@ -0,0 +1,38 @@
/**
* Shared Player Modals Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useState } from 'react';
import { Episode } from '../../../types/metadata';
export const usePlayerModals = () => {
const [showAudioModal, setShowAudioModal] = useState(false);
const [showSubtitleModal, setShowSubtitleModal] = useState(false);
const [showSpeedModal, setShowSpeedModal] = useState(false);
const [showSourcesModal, setShowSourcesModal] = useState(false);
const [showEpisodesModal, setShowEpisodesModal] = useState(false);
const [showEpisodeStreamsModal, setShowEpisodeStreamsModal] = useState(false);
const [showErrorModal, setShowErrorModal] = useState(false);
const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false);
const [showCastDetails, setShowCastDetails] = useState(false);
// Some modals have associated data
const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState<Episode | null>(null);
const [errorDetails, setErrorDetails] = useState<string>('');
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
return {
showAudioModal, setShowAudioModal,
showSubtitleModal, setShowSubtitleModal,
showSpeedModal, setShowSpeedModal,
showSourcesModal, setShowSourcesModal,
showEpisodesModal, setShowEpisodesModal,
showEpisodeStreamsModal, setShowEpisodeStreamsModal,
showErrorModal, setShowErrorModal,
showSubtitleLanguageModal, setShowSubtitleLanguageModal,
showCastDetails, setShowCastDetails,
selectedEpisodeForStreams, setSelectedEpisodeForStreams,
errorDetails, setErrorDetails,
selectedCastMember, setSelectedCastMember
};
};

View file

@ -0,0 +1,117 @@
/**
* Shared Player Setup Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
* Handles StatusBar, orientation, brightness, and app state
*/
import { useEffect, useRef, useCallback } from 'react';
import { StatusBar, Dimensions, AppState, InteractionManager, Platform } from 'react-native';
import * as Brightness from 'expo-brightness';
import * as ScreenOrientation from 'expo-screen-orientation';
import { logger } from '../../../utils/logger';
import { useFocusEffect } from '@react-navigation/native';
interface PlayerSetupConfig {
setScreenDimensions: (dim: any) => void;
setVolume: (vol: number) => void;
setBrightness: (bri: number) => void;
isOpeningAnimationComplete: boolean;
}
export const usePlayerSetup = (config: PlayerSetupConfig) => {
const {
setScreenDimensions,
setVolume,
setBrightness,
isOpeningAnimationComplete
} = config;
const isAppBackgrounded = useRef(false);
const enableImmersiveMode = () => {
StatusBar.setHidden(true, 'none');
};
const disableImmersiveMode = () => {
StatusBar.setHidden(false, 'fade');
};
useFocusEffect(
useCallback(() => {
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
return () => { };
}, [isOpeningAnimationComplete])
);
useEffect(() => {
// Initial Setup
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
setScreenDimensions(screen);
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
});
StatusBar.setHidden(true, 'none');
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
// Initialize volume (normalized 0-1 for cross-platform)
setVolume(1.0);
// Initialize Brightness
const initBrightness = () => {
InteractionManager.runAfterInteractions(async () => {
try {
const currentBrightness = await Brightness.getBrightnessAsync();
setBrightness(currentBrightness);
} catch (error) {
logger.warn('[usePlayerSetup] Error getting initial brightness:', error);
setBrightness(1.0);
}
});
};
initBrightness();
return () => {
subscription?.remove();
disableImmersiveMode();
};
}, [isOpeningAnimationComplete]);
// Handle Orientation (Lock to Landscape after opening)
useEffect(() => {
if (isOpeningAnimationComplete) {
const task = InteractionManager.runAfterInteractions(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE)
.then(() => {
if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation');
})
.catch((error) => {
logger.warn('[VideoPlayer] Failed to lock orientation:', error);
});
});
return () => task.cancel();
}
}, [isOpeningAnimationComplete]);
// Handle App State
useEffect(() => {
const onAppStateChange = (state: string) => {
if (state === 'active') {
isAppBackgrounded.current = false;
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
} else if (state === 'background' || state === 'inactive') {
isAppBackgrounded.current = true;
}
};
const sub = AppState.addEventListener('change', onAppStateChange);
return () => sub.remove();
}, [isOpeningAnimationComplete]);
return { isAppBackgrounded };
};

View file

@ -0,0 +1,88 @@
/**
* Shared Player State Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useState, useRef } from 'react';
import { Dimensions, Platform } from 'react-native';
// Use only resize modes supported by all player backends
// (not all players support 'stretch' or 'none')
export type PlayerResizeMode = 'contain' | 'cover';
export const usePlayerState = () => {
// Playback State
const [paused, setPaused] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState(0);
const [isBuffering, setIsBuffering] = useState(false);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPlayerReady, setIsPlayerReady] = useState(false);
// UI State
const [showControls, setShowControls] = useState(true);
const [resizeMode, setResizeMode] = useState<PlayerResizeMode>('contain');
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
const [is16by9Content, setIs16by9Content] = useState(false);
const screenData = Dimensions.get('screen');
const [screenDimensions, setScreenDimensions] = useState(screenData);
// Zoom State
const [zoomScale, setZoomScale] = useState(1);
const [zoomTranslateX, setZoomTranslateX] = useState(0);
const [zoomTranslateY, setZoomTranslateY] = useState(0);
const [lastZoomScale, setLastZoomScale] = useState(1);
const [lastTranslateX, setLastTranslateX] = useState(0);
const [lastTranslateY, setLastTranslateY] = useState(0);
// AirPlay State (iOS only, but keeping it here for unified interface)
const [isAirPlayActive, setIsAirPlayActive] = useState<boolean>(false);
const [allowsAirPlay, setAllowsAirPlay] = useState<boolean>(true);
// Logic State
const isSeeking = useRef(false);
const isDragging = useRef(false);
const isMounted = useRef(true);
const seekDebounceTimer = useRef<NodeJS.Timeout | null>(null);
const pendingSeekValue = useRef<number | null>(null);
const lastSeekTime = useRef<number>(0);
const wasPlayingBeforeDragRef = useRef<boolean>(false);
// Helper for iPad/macOS fullscreen
const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000);
const isMacOS = Platform.OS === 'ios' && Platform.isPad === true;
const shouldUseFullscreen = isIPad || isMacOS;
const windowData = Dimensions.get('window');
const effectiveDimensions = shouldUseFullscreen ? windowData : screenDimensions;
return {
paused, setPaused,
currentTime, setCurrentTime,
duration, setDuration,
buffered, setBuffered,
isBuffering, setIsBuffering,
isVideoLoaded, setIsVideoLoaded,
isPlayerReady, setIsPlayerReady,
showControls, setShowControls,
resizeMode, setResizeMode,
videoAspectRatio, setVideoAspectRatio,
is16by9Content, setIs16by9Content,
screenDimensions, setScreenDimensions,
zoomScale, setZoomScale,
zoomTranslateX, setZoomTranslateX,
zoomTranslateY, setZoomTranslateY,
lastZoomScale, setLastZoomScale,
lastTranslateX, setLastTranslateX,
lastTranslateY, setLastTranslateY,
isAirPlayActive, setIsAirPlayActive,
allowsAirPlay, setAllowsAirPlay,
isSeeking,
isDragging,
isMounted,
seekDebounceTimer,
pendingSeekValue,
lastSeekTime,
wasPlayingBeforeDragRef,
effectiveDimensions
};
};

View file

@ -0,0 +1,47 @@
/**
* Shared Player Tracks Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useState, useCallback } from 'react';
import { AudioTrack, TextTrack } from '../utils/playerTypes';
export const usePlayerTracks = () => {
// React-native-video style tracks
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null);
const [textTracks, setTextTracks] = useState<TextTrack[]>([]);
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
// KS/VLC style tracks (simpler format)
const [ksAudioTracks, setKsAudioTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
const [ksTextTracks, setKsTextTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
// Derived states
const hasAudioTracks = audioTracks.length > 0 || ksAudioTracks.length > 0;
const hasTextTracks = textTracks.length > 0 || ksTextTracks.length > 0;
// Track selection functions
const selectAudioTrack = useCallback((trackId: number) => {
setSelectedAudioTrack(trackId);
}, []);
const selectTextTrack = useCallback((trackId: number) => {
setSelectedTextTrack(trackId);
}, []);
return {
// Standard tracks
audioTracks, setAudioTracks,
selectedAudioTrack, setSelectedAudioTrack,
textTracks, setTextTracks,
selectedTextTrack, setSelectedTextTrack,
// KS/VLC tracks
ksAudioTracks, setKsAudioTracks,
ksTextTracks, setKsTextTracks,
// Helpers
hasAudioTracks,
hasTextTracks,
selectAudioTrack,
selectTextTrack
};
};

View file

@ -0,0 +1,97 @@
/**
* Shared Speed Control Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import { useState, useRef, useCallback, useEffect } from 'react';
import { Animated } from 'react-native';
import { mmkvStorage } from '../../../services/mmkvStorage';
import { logger } from '../../../utils/logger';
const SPEED_SETTINGS_KEY = '@nuvio_speed_settings';
export const useSpeedControl = (initialSpeed: number = 1.0) => {
const [playbackSpeed, setPlaybackSpeed] = useState<number>(initialSpeed);
const [holdToSpeedEnabled, setHoldToSpeedEnabled] = useState(true);
const [holdToSpeedValue, setHoldToSpeedValue] = useState(2.0);
const [isSpeedBoosted, setIsSpeedBoosted] = useState(false);
const [originalSpeed, setOriginalSpeed] = useState<number>(initialSpeed);
const [showSpeedActivatedOverlay, setShowSpeedActivatedOverlay] = useState(false);
const speedActivatedOverlayOpacity = useRef(new Animated.Value(0)).current;
// Load Settings
useEffect(() => {
const loadSettings = async () => {
try {
const saved = await mmkvStorage.getItem(SPEED_SETTINGS_KEY);
if (saved) {
const settings = JSON.parse(saved);
if (typeof settings.holdToSpeedEnabled === 'boolean') setHoldToSpeedEnabled(settings.holdToSpeedEnabled);
if (typeof settings.holdToSpeedValue === 'number') setHoldToSpeedValue(settings.holdToSpeedValue);
}
} catch (e) {
logger.warn('[useSpeedControl] Error loading settings', e);
}
};
loadSettings();
}, []);
// Save Settings
useEffect(() => {
const saveSettings = async () => {
try {
await mmkvStorage.setItem(SPEED_SETTINGS_KEY, JSON.stringify({
holdToSpeedEnabled,
holdToSpeedValue
}));
} catch (e) { }
};
saveSettings();
}, [holdToSpeedEnabled, holdToSpeedValue]);
const activateSpeedBoost = useCallback(() => {
if (!holdToSpeedEnabled || isSpeedBoosted || playbackSpeed === holdToSpeedValue) return;
setOriginalSpeed(playbackSpeed);
setPlaybackSpeed(holdToSpeedValue);
setIsSpeedBoosted(true);
setShowSpeedActivatedOverlay(true);
Animated.timing(speedActivatedOverlayOpacity, {
toValue: 1,
duration: 200,
useNativeDriver: true
}).start();
setTimeout(() => {
Animated.timing(speedActivatedOverlayOpacity, {
toValue: 0,
duration: 300,
useNativeDriver: true
}).start(() => setShowSpeedActivatedOverlay(false));
}, 2000);
}, [holdToSpeedEnabled, isSpeedBoosted, playbackSpeed, holdToSpeedValue]);
const deactivateSpeedBoost = useCallback(() => {
if (isSpeedBoosted) {
setPlaybackSpeed(originalSpeed);
setIsSpeedBoosted(false);
Animated.timing(speedActivatedOverlayOpacity, { toValue: 0, duration: 100, useNativeDriver: true }).start();
}
}, [isSpeedBoosted, originalSpeed]);
return {
playbackSpeed,
setPlaybackSpeed,
holdToSpeedEnabled,
setHoldToSpeedEnabled,
holdToSpeedValue,
setHoldToSpeedValue,
isSpeedBoosted,
activateSpeedBoost,
deactivateSpeedBoost,
showSpeedActivatedOverlay,
speedActivatedOverlayOpacity
};
};

View file

@ -0,0 +1,333 @@
import React from 'react';
import { View, Text, Animated } from 'react-native';
import {
TapGestureHandler,
PanGestureHandler,
LongPressGestureHandler,
} from 'react-native-gesture-handler';
import { MaterialIcons } from '@expo/vector-icons';
interface GestureControlsProps {
screenDimensions: { width: number, height: number };
gestureControls: any;
onLongPressActivated: () => void;
onLongPressEnd: () => void;
onLongPressStateChange: (event: any) => void;
toggleControls: () => void;
showControls: boolean;
hideControls: () => void;
volume: number;
brightness: number;
controlsTimeout: React.MutableRefObject<NodeJS.Timeout | null>;
}
export const GestureControls: React.FC<GestureControlsProps> = ({
screenDimensions,
gestureControls,
onLongPressActivated,
onLongPressEnd,
onLongPressStateChange,
toggleControls,
showControls,
hideControls,
volume,
brightness,
controlsTimeout
}) => {
// Helper to get dimensions (using passed screenDimensions)
const getDimensions = () => screenDimensions;
return (
<>
{/* Left side gesture handler - brightness + tap + long press */}
<LongPressGestureHandler
onActivated={onLongPressActivated}
onEnded={onLongPressEnd}
onHandlerStateChange={onLongPressStateChange}
minDurationMs={500}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<PanGestureHandler
onGestureEvent={gestureControls.onBrightnessGestureEvent}
activeOffsetY={[-10, 10]}
failOffsetX={[-30, 30]}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
maxPointers={1}
>
<TapGestureHandler
onActivated={toggleControls}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15,
left: 0,
width: screenDimensions.width * 0.4,
height: screenDimensions.height * 0.7,
zIndex: 10,
}} />
</TapGestureHandler>
</PanGestureHandler>
</LongPressGestureHandler>
{/* Right side gesture handler - volume + tap + long press */}
<LongPressGestureHandler
onActivated={onLongPressActivated}
onEnded={onLongPressEnd}
onHandlerStateChange={onLongPressStateChange}
minDurationMs={500}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<PanGestureHandler
onGestureEvent={gestureControls.onVolumeGestureEvent}
activeOffsetY={[-10, 10]}
failOffsetX={[-30, 30]}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
maxPointers={1}
>
<TapGestureHandler
onActivated={toggleControls}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15,
right: 0,
width: screenDimensions.width * 0.4,
height: screenDimensions.height * 0.7,
zIndex: 10,
}} />
</TapGestureHandler>
</PanGestureHandler>
</LongPressGestureHandler>
{/* Center area tap handler */}
<TapGestureHandler
onActivated={() => {
if (showControls) {
const timeoutId = setTimeout(() => {
hideControls();
}, 0);
if (controlsTimeout.current) {
clearTimeout(controlsTimeout.current);
}
controlsTimeout.current = timeoutId;
} else {
toggleControls();
}
}}
shouldCancelWhenOutside={false}
simultaneousHandlers={[]}
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15,
left: screenDimensions.width * 0.4,
width: screenDimensions.width * 0.2,
height: screenDimensions.height * 0.7,
zIndex: 5,
}} />
</TapGestureHandler>
{/* Volume Overlay */}
{gestureControls.showVolumeOverlay && (
<Animated.View
style={{
position: 'absolute',
left: getDimensions().width / 2 - 60,
top: getDimensions().height / 2 - 60,
opacity: gestureControls.volumeOverlayOpacity,
zIndex: 1000,
}}
>
<View style={{
backgroundColor: 'rgba(0, 0, 0, 0.9)',
borderRadius: 12,
padding: 16,
alignItems: 'center',
width: 120,
height: 120,
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.5,
shadowRadius: 8,
elevation: 10,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
}}>
<MaterialIcons
name={volume === 0 ? "volume-off" : volume < 30 ? "volume-mute" : volume < 70 ? "volume-down" : "volume-up"}
size={24}
color={volume === 0 ? "#FF6B6B" : "#FFFFFF"}
style={{ marginBottom: 8 }}
/>
{/* Horizontal Dotted Progress Bar */}
<View style={{
width: 80,
height: 6,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 3,
position: 'relative',
overflow: 'hidden',
marginBottom: 8,
}}>
{/* Dotted background */}
<View style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 1,
}}>
{Array.from({ length: 16 }, (_, i) => (
<View
key={i}
style={{
width: 1.5,
height: 1.5,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
borderRadius: 0.75,
}}
/>
))}
</View>
{/* Progress fill */}
<View style={{
position: 'absolute',
top: 0,
left: 0,
width: `${volume}%`,
height: 6,
backgroundColor: volume === 0 ? '#FF6B6B' : '#E50914',
borderRadius: 3,
shadowColor: volume === 0 ? '#FF6B6B' : '#E50914',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.6,
shadowRadius: 2,
}} />
</View>
<Text style={{
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
letterSpacing: 0.5,
}}>
{Math.round(volume)}%
</Text>
</View>
</Animated.View>
)}
{/* Brightness Overlay */}
{gestureControls.showBrightnessOverlay && (
<Animated.View
style={{
position: 'absolute',
left: getDimensions().width / 2 - 60,
top: getDimensions().height / 2 - 60,
opacity: gestureControls.brightnessOverlayOpacity,
zIndex: 1000,
}}
>
<View style={{
backgroundColor: 'rgba(0, 0, 0, 0.9)',
borderRadius: 12,
padding: 16,
alignItems: 'center',
width: 120,
height: 120,
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.5,
shadowRadius: 8,
elevation: 10,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
}}>
<MaterialIcons
name={brightness < 0.2 ? "brightness-low" : brightness < 0.5 ? "brightness-medium" : brightness < 0.8 ? "brightness-high" : "brightness-auto"}
size={24}
color={brightness < 0.2 ? "#FFD700" : "#FFFFFF"}
style={{ marginBottom: 8 }}
/>
{/* Horizontal Dotted Progress Bar */}
<View style={{
width: 80,
height: 6,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 3,
position: 'relative',
overflow: 'hidden',
marginBottom: 8,
}}>
{/* Dotted background */}
<View style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 1,
}}>
{Array.from({ length: 16 }, (_, i) => (
<View
key={i}
style={{
width: 1.5,
height: 1.5,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
borderRadius: 0.75,
}}
/>
))}
</View>
{/* Progress fill */}
<View style={{
position: 'absolute',
top: 0,
left: 0,
width: `${brightness * 100}%`,
height: 6,
backgroundColor: brightness < 0.2 ? '#FFD700' : '#FFA500',
borderRadius: 3,
shadowColor: brightness < 0.2 ? '#FFD700' : '#FFA500',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.6,
shadowRadius: 2,
}} />
</View>
<Text style={{
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
letterSpacing: 0.5,
}}>
{Math.round(brightness * 100)}%
</Text>
</View>
</Animated.View>
)}
</>
);
};

View file

@ -0,0 +1,140 @@
import React, { useRef } from 'react';
import { Animated } from 'react-native';
import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler';
import KSPlayerComponent, { KSPlayerRef, KSPlayerSource } from '../../KSPlayerComponent';
interface KSPlayerSurfaceProps {
ksPlayerRef: React.RefObject<KSPlayerRef>;
uri: string;
headers?: Record<string, string>;
paused: boolean;
volume: number;
playbackSpeed: number;
resizeMode: 'contain' | 'cover' | 'stretch';
zoomScale: number;
setZoomScale: (scale: number) => void;
lastZoomScale: number;
setLastZoomScale: (scale: number) => void;
// Tracks - use number directly
audioTrack?: number;
textTrack?: number;
onAudioTracks: (data: any) => void;
onTextTracks: (data: any) => void;
// Handlers
onLoad: (data: any) => void;
onProgress: (data: any) => void;
onEnd: () => void;
onError: (error: any) => void;
onBuffer: (isBuffering: boolean) => void;
onReadyForDisplay: () => void;
onPlaybackStalled: () => void;
onPlaybackResume: () => void;
// Dimensions
screenWidth: number;
screenHeight: number;
customVideoStyles: any;
}
export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
ksPlayerRef,
uri,
headers,
paused,
volume,
playbackSpeed,
resizeMode,
zoomScale,
setZoomScale,
lastZoomScale,
setLastZoomScale,
audioTrack,
textTrack,
onAudioTracks,
onTextTracks,
onLoad,
onProgress,
onEnd,
onError,
onBuffer,
onReadyForDisplay,
onPlaybackStalled,
onPlaybackResume,
screenWidth,
screenHeight,
customVideoStyles
}) => {
const pinchRef = useRef<PinchGestureHandler>(null);
const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => {
const { scale } = event.nativeEvent;
// Limit max zoom to 1.1x as per original logic, min 1
const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1));
setZoomScale(newScale);
};
const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => {
if (event.nativeEvent.state === State.END) {
setLastZoomScale(zoomScale);
}
};
// Create source object for KSPlayerComponent
const source: KSPlayerSource = {
uri,
headers
};
// Handle buffering - KSPlayerComponent uses onBuffering callback
const handleBuffering = (data: any) => {
onBuffer(data?.isBuffering ?? false);
};
// Handle load - also extract tracks if available
const handleLoad = (data: any) => {
onLoad(data);
// Extract tracks if present in load data
if (data?.audioTracks) {
onAudioTracks({ audioTracks: data.audioTracks });
}
if (data?.textTracks) {
onTextTracks({ textTracks: data.textTracks });
}
// Notify ready for display
onReadyForDisplay();
};
return (
<PinchGestureHandler
ref={pinchRef}
onGestureEvent={onPinchGestureEvent}
onHandlerStateChange={onPinchHandlerStateChange}
>
<Animated.View style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
transform: [{ scale: zoomScale }]
}}>
<KSPlayerComponent
ref={ksPlayerRef}
source={source}
paused={paused}
volume={volume}
rate={playbackSpeed}
resizeMode={resizeMode}
audioTrack={audioTrack}
textTrack={textTrack}
onLoad={handleLoad}
onProgress={onProgress}
onBuffering={handleBuffering}
onEnd={onEnd}
onError={onError}
style={customVideoStyles.width ? customVideoStyles : { width: screenWidth, height: screenHeight }}
/>
</Animated.View>
</PinchGestureHandler>
);
};

View file

@ -0,0 +1,228 @@
import React, { useState, useRef } from 'react';
import { View, Text, TouchableOpacity, Animated, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface PauseOverlayProps {
visible: boolean;
onClose: () => void;
title: string;
episodeTitle?: string;
season?: number;
episode?: number;
year?: string | number;
type: string;
description: string;
cast: any[];
screenDimensions: { width: number, height: number };
}
export const PauseOverlay: React.FC<PauseOverlayProps> = ({
visible,
onClose,
title,
episodeTitle,
season,
episode,
year,
type,
description,
cast,
screenDimensions
}) => {
const insets = useSafeAreaInsets();
// Internal Animation State
const pauseOverlayOpacity = useRef(new Animated.Value(visible ? 1 : 0)).current;
const pauseOverlayTranslateY = useRef(new Animated.Value(12)).current;
const metadataOpacity = useRef(new Animated.Value(1)).current;
const metadataScale = useRef(new Animated.Value(1)).current;
// Cast Details State
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
const [showCastDetails, setShowCastDetails] = useState(false);
const castDetailsOpacity = useRef(new Animated.Value(0)).current;
const castDetailsScale = useRef(new Animated.Value(0.95)).current;
React.useEffect(() => {
Animated.timing(pauseOverlayOpacity, {
toValue: visible ? 1 : 0,
duration: 250,
useNativeDriver: true
}).start();
}, [visible]);
if (!visible && !showCastDetails) return null;
return (
<TouchableOpacity
activeOpacity={1}
onPress={onClose}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 30,
}}
>
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: pauseOverlayOpacity,
}}
>
{/* Horizontal Fade */}
<View style={{ position: 'absolute', top: 0, left: 0, bottom: 0, width: screenDimensions.width * 0.7 }}>
<LinearGradient
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
colors={['rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)']}
locations={[0, 1]}
style={StyleSheet.absoluteFill}
/>
</View>
<LinearGradient
colors={[
'rgba(0,0,0,0.6)',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.2)',
'rgba(0,0,0,0.0)'
]}
locations={[0, 0.3, 0.6, 1]}
style={StyleSheet.absoluteFill}
/>
<Animated.View style={{
position: 'absolute',
left: 24 + insets.left,
right: 24 + insets.right,
top: 24 + insets.top,
bottom: 110 + insets.bottom,
transform: [{ translateY: pauseOverlayTranslateY }]
}}>
{showCastDetails && selectedCastMember ? (
<Animated.View
style={{
flex: 1,
justifyContent: 'center',
opacity: castDetailsOpacity,
transform: [{ scale: castDetailsScale }]
}}
>
<View style={{ alignItems: 'flex-start', paddingBottom: screenDimensions.height * 0.1 }}>
<TouchableOpacity
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 24, paddingVertical: 8, paddingHorizontal: 4 }}
onPress={() => {
Animated.parallel([
Animated.timing(castDetailsOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(castDetailsScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
]).start(() => {
setShowCastDetails(false);
setSelectedCastMember(null);
Animated.parallel([
Animated.timing(metadataOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(metadataScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
]).start();
});
}}
>
<MaterialIcons name="arrow-back" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
<Text style={{ color: '#B8B8B8', fontSize: Math.min(14, screenDimensions.width * 0.02) }}>Back to details</Text>
</TouchableOpacity>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', width: '100%' }}>
{selectedCastMember.profile_path && (
<View style={{ marginRight: 20, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 5 }}>
<FastImage
source={{ uri: `https://image.tmdb.org/t/p/w300${selectedCastMember.profile_path}` }}
style={{ width: Math.min(120, screenDimensions.width * 0.18), height: Math.min(180, screenDimensions.width * 0.27), borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.1)' }}
resizeMode={FastImage.resizeMode.cover}
/>
</View>
)}
<View style={{ flex: 1, paddingTop: 8 }}>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(32, screenDimensions.width * 0.045), fontWeight: '800', marginBottom: 8 }} numberOfLines={2}>
{selectedCastMember.name}
</Text>
{selectedCastMember.character && (
<Text style={{ color: '#CCCCCC', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8, fontWeight: '500', fontStyle: 'italic' }} numberOfLines={2}>
as {selectedCastMember.character}
</Text>
)}
{selectedCastMember.biography && (
<Text style={{ color: '#D6D6D6', fontSize: Math.min(14, screenDimensions.width * 0.019), lineHeight: Math.min(20, screenDimensions.width * 0.026), marginTop: 16, opacity: 0.9 }} numberOfLines={4}>
{selectedCastMember.biography}
</Text>
)}
</View>
</View>
</View>
</Animated.View>
) : (
<Animated.View style={{ flex: 1, justifyContent: 'space-between', opacity: metadataOpacity, transform: [{ scale: metadataScale }] }}>
<View>
<Text style={{ color: '#B8B8B8', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }}>You're watching</Text>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(48, screenDimensions.width * 0.06), fontWeight: '800', marginBottom: 10 }} numberOfLines={2}>
{title}
</Text>
{!!year && (
<Text style={{ color: '#CCCCCC', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }} numberOfLines={1}>
{`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`}
</Text>
)}
{!!episodeTitle && (
<Text style={{ color: '#FFFFFF', fontSize: Math.min(20, screenDimensions.width * 0.03), fontWeight: '600', marginBottom: 8 }} numberOfLines={2}>
{episodeTitle}
</Text>
)}
{description && (
<Text style={{ color: '#D6D6D6', fontSize: Math.min(18, screenDimensions.width * 0.025), lineHeight: Math.min(24, screenDimensions.width * 0.03) }} numberOfLines={3}>
{description}
</Text>
)}
{cast && cast.length > 0 && (
<View style={{ marginTop: 16 }}>
<Text style={{ color: '#B8B8B8', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8 }}>Cast</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{cast.slice(0, 6).map((castMember: any, index: number) => (
<TouchableOpacity
key={castMember.id || index}
style={{ backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: Math.min(12, screenDimensions.width * 0.015), paddingVertical: Math.min(6, screenDimensions.height * 0.008), marginRight: 8, marginBottom: 8 }}
onPress={() => {
setSelectedCastMember(castMember);
Animated.parallel([
Animated.timing(metadataOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(metadataScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
]).start(() => {
setShowCastDetails(true);
Animated.parallel([
Animated.timing(castDetailsOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(castDetailsScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
]).start();
});
}}
>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(14, screenDimensions.width * 0.018) }}>
{castMember.name}
</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
</View>
</Animated.View>
)}
</Animated.View>
</Animated.View>
</TouchableOpacity>
);
};

View file

@ -0,0 +1,32 @@
import React from 'react';
import { View, Text, Animated } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { styles } from '../../utils/playerStyles';
interface SpeedActivatedOverlayProps {
visible: boolean;
opacity: Animated.Value;
speed: number;
}
export const SpeedActivatedOverlay: React.FC<SpeedActivatedOverlayProps> = ({
visible,
opacity,
speed
}) => {
if (!visible) return null;
return (
<Animated.View
style={[
styles.speedActivatedOverlay,
{ opacity: opacity }
]}
>
<View style={styles.speedActivatedContainer}>
<MaterialIcons name="fast-forward" size={32} color="#FFFFFF" />
<Text style={styles.speedActivatedText}>{speed}x Speed</Text>
</View>
</Animated.View>
);
};

View file

@ -0,0 +1,58 @@
import { useState } from 'react';
import {
DEFAULT_SUBTITLE_SIZE,
SubtitleCue,
SubtitleSegment,
WyzieSubtitle
} from '../../utils/playerTypes';
export const useCustomSubtitles = () => {
// Data State
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
const [currentFormattedSegments, setCurrentFormattedSegments] = useState<SubtitleSegment[][]>([]);
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
// Loading State
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState<boolean>(false);
// Styling State
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(false);
const [subtitleTextColor, setSubtitleTextColor] = useState<string>('#FFFFFF');
const [subtitleBgOpacity, setSubtitleBgOpacity] = useState<number>(0.7);
const [subtitleTextShadow, setSubtitleTextShadow] = useState<boolean>(true);
const [subtitleOutline, setSubtitleOutline] = useState<boolean>(true);
const [subtitleOutlineColor, setSubtitleOutlineColor] = useState<string>('#000000');
const [subtitleOutlineWidth, setSubtitleOutlineWidth] = useState<number>(4);
const [subtitleAlign, setSubtitleAlign] = useState<'center' | 'left' | 'right'>('center');
const [subtitleBottomOffset, setSubtitleBottomOffset] = useState<number>(10);
const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState<number>(0);
const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState<number>(1.2);
const [subtitleOffsetSec, setSubtitleOffsetSec] = useState<number>(0);
return {
customSubtitles, setCustomSubtitles,
currentSubtitle, setCurrentSubtitle,
currentFormattedSegments, setCurrentFormattedSegments,
availableSubtitles, setAvailableSubtitles,
useCustomSubtitles, setUseCustomSubtitles,
isLoadingSubtitles, setIsLoadingSubtitles,
isLoadingSubtitleList, setIsLoadingSubtitleList,
subtitleSize, setSubtitleSize,
subtitleBackground, setSubtitleBackground,
subtitleTextColor, setSubtitleTextColor,
subtitleBgOpacity, setSubtitleBgOpacity,
subtitleTextShadow, setSubtitleTextShadow,
subtitleOutline, setSubtitleOutline,
subtitleOutlineColor, setSubtitleOutlineColor,
subtitleOutlineWidth, setSubtitleOutlineWidth,
subtitleAlign, setSubtitleAlign,
subtitleBottomOffset, setSubtitleBottomOffset,
subtitleLetterSpacing, setSubtitleLetterSpacing,
subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier,
subtitleOffsetSec, setSubtitleOffsetSec
};
};

View file

@ -0,0 +1,15 @@
import { useRef } from 'react';
import { KSPlayerRef } from '../../KSPlayerComponent';
export const useKSPlayer = () => {
const ksPlayerRef = useRef<KSPlayerRef>(null);
const seek = (time: number) => {
ksPlayerRef.current?.seek(time);
};
return {
ksPlayerRef,
seek
};
};

View file

@ -0,0 +1,58 @@
import { useMemo } from 'react';
export const useNextEpisode = (
type: string | undefined,
season: number | undefined,
episode: number | undefined,
groupedEpisodes: any,
metadataGroupedEpisodes: any,
episodeId: string | undefined
) => {
// Current description
const currentEpisodeDescription = useMemo(() => {
try {
if (type !== 'series') return '';
const allEpisodes = Object.values(groupedEpisodes || {}).flat() as any[];
if (!allEpisodes || allEpisodes.length === 0) return '';
let match: any | null = null;
if (episodeId) {
match = allEpisodes.find(ep => ep?.stremioId === episodeId || String(ep?.id) === String(episodeId));
}
if (!match && season && episode) {
match = allEpisodes.find(ep => ep?.season_number === season && ep?.episode_number === episode);
}
return (match?.overview || '').trim();
} catch {
return '';
}
}, [type, groupedEpisodes, episodeId, season, episode]);
// Next Episode
const nextEpisode = useMemo(() => {
try {
if (type !== 'series' || !season || !episode) return null;
const sourceGroups = groupedEpisodes && Object.keys(groupedEpisodes || {}).length > 0
? groupedEpisodes
: (metadataGroupedEpisodes || {});
const allEpisodes = Object.values(sourceGroups || {}).flat() as any[];
if (!allEpisodes || allEpisodes.length === 0) return null;
let nextEp = allEpisodes.find((ep: any) =>
ep.season_number === season && ep.episode_number === episode + 1
);
if (!nextEp) {
nextEp = allEpisodes.find((ep: any) =>
ep.season_number === season + 1 && ep.episode_number === 1
);
}
return nextEp;
} catch {
return null;
}
}, [type, season, episode, groupedEpisodes, metadataGroupedEpisodes]);
return { currentEpisodeDescription, nextEpisode };
};

View file

@ -0,0 +1,149 @@
import { useRef, useState, useEffect } from 'react';
import { Animated, InteractionManager } from 'react-native';
import FastImage from '@d11/react-native-fast-image';
import { logger } from '../../../../utils/logger';
export const useOpeningAnimation = (backdrop: string | undefined, metadata: any) => {
// Animation Values
const fadeAnim = useRef(new Animated.Value(1)).current;
const openingFadeAnim = useRef(new Animated.Value(0)).current;
const openingScaleAnim = useRef(new Animated.Value(0.8)).current;
const backgroundFadeAnim = useRef(new Animated.Value(1)).current;
const backdropImageOpacityAnim = useRef(new Animated.Value(0)).current;
const logoScaleAnim = useRef(new Animated.Value(0.8)).current;
const logoOpacityAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current;
const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false);
const [shouldHideOpeningOverlay, setShouldHideOpeningOverlay] = useState(false);
const [isBackdropLoaded, setIsBackdropLoaded] = useState(false);
// Prefetch Background
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
if (backdrop && typeof backdrop === 'string') {
setIsBackdropLoaded(false);
backdropImageOpacityAnim.setValue(0);
try {
FastImage.preload([{ uri: backdrop }]);
setIsBackdropLoaded(true);
Animated.timing(backdropImageOpacityAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}).start();
} catch (error) {
setIsBackdropLoaded(true);
backdropImageOpacityAnim.setValue(1);
}
} else {
setIsBackdropLoaded(true);
backdropImageOpacityAnim.setValue(0);
}
});
return () => task.cancel();
}, [backdrop]);
// Prefetch Logo
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
const logoUrl = metadata?.logo;
if (logoUrl && typeof logoUrl === 'string') {
try {
FastImage.preload([{ uri: logoUrl }]);
} catch (error) { }
}
});
return () => task.cancel();
}, [metadata]);
const startOpeningAnimation = () => {
Animated.parallel([
Animated.timing(logoOpacityAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.spring(logoScaleAnim, {
toValue: 1,
tension: 80,
friction: 8,
useNativeDriver: true,
}),
]).start();
const createPulseAnimation = () => {
return Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
]);
};
const loopPulse = () => {
createPulseAnimation().start(() => {
if (!isOpeningAnimationComplete) {
loopPulse();
}
});
};
loopPulse();
};
const completeOpeningAnimation = () => {
pulseAnim.stopAnimation();
Animated.parallel([
Animated.timing(openingFadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(openingScaleAnim, {
toValue: 1,
duration: 350,
useNativeDriver: true,
}),
Animated.timing(backgroundFadeAnim, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}),
]).start(() => {
setIsOpeningAnimationComplete(true);
setTimeout(() => {
setShouldHideOpeningOverlay(true);
}, 450);
});
setTimeout(() => {
if (!isOpeningAnimationComplete) {
// logger.warn('[VideoPlayer] Opening animation fallback triggered');
setIsOpeningAnimationComplete(true);
}
}, 1000);
};
return {
fadeAnim,
openingFadeAnim,
openingScaleAnim,
backgroundFadeAnim,
backdropImageOpacityAnim,
logoScaleAnim,
logoOpacityAnim,
pulseAnim,
isOpeningAnimationComplete,
shouldHideOpeningOverlay,
isBackdropLoaded,
startOpeningAnimation,
completeOpeningAnimation
};
};

View file

@ -0,0 +1,63 @@
import { useRef, useCallback } from 'react';
import { Platform } from 'react-native';
import { logger } from '../../../../utils/logger';
const DEBUG_MODE = false;
const END_EPSILON = 0.3;
export const usePlayerControls = (
ksPlayerRef: any,
paused: boolean,
setPaused: (paused: boolean) => void,
currentTime: number,
duration: number,
isSeeking: React.MutableRefObject<boolean>,
isMounted: React.MutableRefObject<boolean>
) => {
// iOS seeking helpers
const iosWasPausedDuringSeekRef = useRef<boolean | null>(null);
const togglePlayback = useCallback(() => {
setPaused(!paused);
}, [paused, setPaused]);
const seekToTime = useCallback((rawSeconds: number) => {
const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds));
if (ksPlayerRef.current && duration > 0 && !isSeeking.current) {
if (DEBUG_MODE) logger.log(`[usePlayerControls] Seeking to ${timeInSeconds}`);
isSeeking.current = true;
// iOS optimization: pause while seeking for smoother experience
iosWasPausedDuringSeekRef.current = paused;
if (!paused) setPaused(true);
// Actually perform the seek
ksPlayerRef.current.seek(timeInSeconds);
// Debounce the seeking state reset
setTimeout(() => {
if (isMounted.current && isSeeking.current) {
isSeeking.current = false;
// Resume if it was playing
if (iosWasPausedDuringSeekRef.current === false) {
setPaused(false);
iosWasPausedDuringSeekRef.current = null;
}
}
}, 500);
}
}, [duration, paused, setPaused, ksPlayerRef, isSeeking, isMounted]);
const skip = useCallback((seconds: number) => {
seekToTime(currentTime + seconds);
}, [currentTime, seekToTime]);
return {
togglePlayback,
seekToTime,
skip,
iosWasPausedDuringSeekRef
};
};

View file

@ -0,0 +1,34 @@
import { useState } from 'react';
import { Episode } from '../../../../types/metadata';
export const usePlayerModals = () => {
const [showAudioModal, setShowAudioModal] = useState(false);
const [showSubtitleModal, setShowSubtitleModal] = useState(false);
const [showSpeedModal, setShowSpeedModal] = useState(false);
const [showSourcesModal, setShowSourcesModal] = useState(false);
const [showEpisodesModal, setShowEpisodesModal] = useState(false);
const [showEpisodeStreamsModal, setShowEpisodeStreamsModal] = useState(false);
const [showErrorModal, setShowErrorModal] = useState(false);
const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false);
const [showCastDetails, setShowCastDetails] = useState(false);
// Some modals have associated data
const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState<Episode | null>(null);
const [errorDetails, setErrorDetails] = useState<string>('');
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
return {
showAudioModal, setShowAudioModal,
showSubtitleModal, setShowSubtitleModal,
showSpeedModal, setShowSpeedModal,
showSourcesModal, setShowSourcesModal,
showEpisodesModal, setShowEpisodesModal,
showEpisodeStreamsModal, setShowEpisodeStreamsModal,
showErrorModal, setShowErrorModal,
showSubtitleLanguageModal, setShowSubtitleLanguageModal,
showCastDetails, setShowCastDetails,
selectedEpisodeForStreams, setSelectedEpisodeForStreams,
errorDetails, setErrorDetails,
selectedCastMember, setSelectedCastMember
};
};

View file

@ -0,0 +1,103 @@
import { useEffect, useRef, useCallback } from 'react';
import { StatusBar, Dimensions, AppState, InteractionManager } from 'react-native';
import * as Brightness from 'expo-brightness';
import * as ScreenOrientation from 'expo-screen-orientation';
import { logger } from '../../../../utils/logger';
import { useFocusEffect } from '@react-navigation/native';
export const usePlayerSetup = (
setScreenDimensions: (dim: any) => void,
setVolume: (vol: number) => void,
setBrightness: (bri: number) => void,
isOpeningAnimationComplete: boolean
) => {
const isAppBackgrounded = useRef(false);
const enableImmersiveMode = () => {
StatusBar.setHidden(true, 'none');
};
const disableImmersiveMode = () => {
StatusBar.setHidden(false, 'fade');
};
useFocusEffect(
useCallback(() => {
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
return () => { };
}, [isOpeningAnimationComplete])
);
useEffect(() => {
// Initial Setup
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
setScreenDimensions(screen);
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
});
StatusBar.setHidden(true, 'none');
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
// Initialize volume (KSPlayer uses 0-100)
setVolume(100);
// Initialize Brightness
const initBrightness = () => {
InteractionManager.runAfterInteractions(async () => {
try {
const currentBrightness = await Brightness.getBrightnessAsync();
setBrightness(currentBrightness);
} catch (error) {
logger.warn('[usePlayerSetup] Error getting initial brightness:', error);
setBrightness(1.0);
}
});
};
initBrightness();
return () => {
subscription?.remove();
disableImmersiveMode();
};
}, [isOpeningAnimationComplete]);
// Handle Orientation (Lock to Landscape after opening)
useEffect(() => {
if (isOpeningAnimationComplete) {
const task = InteractionManager.runAfterInteractions(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE)
.then(() => {
if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation');
})
.catch((error) => {
logger.warn('[VideoPlayer] Failed to lock orientation:', error);
});
});
return () => task.cancel();
}
}, [isOpeningAnimationComplete]);
// Handle App State
useEffect(() => {
const onAppStateChange = (state: string) => {
if (state === 'active') {
isAppBackgrounded.current = false;
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
} else if (state === 'background' || state === 'inactive') {
isAppBackgrounded.current = true;
}
};
const sub = AppState.addEventListener('change', onAppStateChange);
return () => sub.remove();
}, [isOpeningAnimationComplete]);
return { isAppBackgrounded };
};

View file

@ -0,0 +1,83 @@
import { useState, useRef } from 'react';
import { Dimensions, Platform } from 'react-native';
// Use a specific type for resizeMode that matches what KSPlayerComponent supports
type PlayerResizeMode = 'contain' | 'cover' | 'stretch';
export const usePlayerState = () => {
// Playback State
const [paused, setPaused] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState(0);
const [isBuffering, setIsBuffering] = useState(false);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPlayerReady, setIsPlayerReady] = useState(false);
// UI State
const [showControls, setShowControls] = useState(true);
const [resizeMode, setResizeMode] = useState<PlayerResizeMode>('contain');
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
const [is16by9Content, setIs16by9Content] = useState(false);
const screenData = Dimensions.get('screen');
const [screenDimensions, setScreenDimensions] = useState(screenData);
// Zoom State
const [zoomScale, setZoomScale] = useState(1);
const [zoomTranslateX, setZoomTranslateX] = useState(0);
const [zoomTranslateY, setZoomTranslateY] = useState(0);
const [lastZoomScale, setLastZoomScale] = useState(1);
const [lastTranslateX, setLastTranslateX] = useState(0);
const [lastTranslateY, setLastTranslateY] = useState(0);
// AirPlay State
const [isAirPlayActive, setIsAirPlayActive] = useState<boolean>(false);
const [allowsAirPlay, setAllowsAirPlay] = useState<boolean>(true);
// Logic State
const isSeeking = useRef(false);
const isDragging = useRef(false);
const isMounted = useRef(true);
const seekDebounceTimer = useRef<NodeJS.Timeout | null>(null);
const pendingSeekValue = useRef<number | null>(null);
const lastSeekTime = useRef<number>(0);
const wasPlayingBeforeDragRef = useRef<boolean>(false);
// Helper for iPad/macOS fullscreen
const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000);
const isMacOS = Platform.OS === 'ios' && Platform.isPad === true;
const shouldUseFullscreen = isIPad || isMacOS;
const windowData = Dimensions.get('window');
const effectiveDimensions = shouldUseFullscreen ? windowData : screenDimensions;
return {
paused, setPaused,
currentTime, setCurrentTime,
duration, setDuration,
buffered, setBuffered,
isBuffering, setIsBuffering,
isVideoLoaded, setIsVideoLoaded,
isPlayerReady, setIsPlayerReady,
showControls, setShowControls,
resizeMode, setResizeMode,
videoAspectRatio, setVideoAspectRatio,
is16by9Content, setIs16by9Content,
screenDimensions, setScreenDimensions,
zoomScale, setZoomScale,
zoomTranslateX, setZoomTranslateX,
zoomTranslateY, setZoomTranslateY,
lastZoomScale, setLastZoomScale,
lastTranslateX, setLastTranslateX,
lastTranslateY, setLastTranslateY,
isAirPlayActive, setIsAirPlayActive,
allowsAirPlay, setAllowsAirPlay,
isSeeking,
isDragging,
isMounted,
seekDebounceTimer,
pendingSeekValue,
lastSeekTime,
wasPlayingBeforeDragRef,
effectiveDimensions
};
};

View file

@ -0,0 +1,38 @@
import { useState, useMemo, useCallback } from 'react';
import { AudioTrack, TextTrack } from '../../utils/playerTypes';
export const usePlayerTracks = () => {
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null);
const [textTracks, setTextTracks] = useState<TextTrack[]>([]);
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
const [ksAudioTracks, setKsAudioTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
const [ksTextTracks, setKsTextTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
// Derived states or logic
const hasAudioTracks = audioTracks.length > 0;
const hasTextTracks = textTracks.length > 0;
// Track selection functions
const selectAudioTrack = useCallback((trackId: number) => {
setSelectedAudioTrack(trackId);
}, []);
const selectTextTrack = useCallback((trackId: number) => {
setSelectedTextTrack(trackId);
}, []);
return {
audioTracks, setAudioTracks,
selectedAudioTrack, setSelectedAudioTrack,
textTracks, setTextTracks,
selectedTextTrack, setSelectedTextTrack,
ksAudioTracks, setKsAudioTracks,
ksTextTracks, setKsTextTracks,
hasAudioTracks,
hasTextTracks,
selectAudioTrack,
selectTextTrack
};
};

View file

@ -0,0 +1,93 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Animated } from 'react-native';
import { mmkvStorage } from '../../../../services/mmkvStorage';
import { logger } from '../../../../utils/logger';
const SPEED_SETTINGS_KEY = '@nuvio_speed_settings';
export const useSpeedControl = (initialSpeed: number = 1.0) => {
const [playbackSpeed, setPlaybackSpeed] = useState<number>(initialSpeed);
const [holdToSpeedEnabled, setHoldToSpeedEnabled] = useState(true);
const [holdToSpeedValue, setHoldToSpeedValue] = useState(2.0);
const [isSpeedBoosted, setIsSpeedBoosted] = useState(false);
const [originalSpeed, setOriginalSpeed] = useState<number>(initialSpeed);
const [showSpeedActivatedOverlay, setShowSpeedActivatedOverlay] = useState(false);
const speedActivatedOverlayOpacity = useRef(new Animated.Value(0)).current;
// Load Settings
useEffect(() => {
const loadSettings = async () => {
try {
const saved = await mmkvStorage.getItem(SPEED_SETTINGS_KEY);
if (saved) {
const settings = JSON.parse(saved);
if (typeof settings.holdToSpeedEnabled === 'boolean') setHoldToSpeedEnabled(settings.holdToSpeedEnabled);
if (typeof settings.holdToSpeedValue === 'number') setHoldToSpeedValue(settings.holdToSpeedValue);
}
} catch (e) {
logger.warn('[useSpeedControl] Error loading settings', e);
}
};
loadSettings();
}, []);
// Save Settings
useEffect(() => {
const saveSettings = async () => {
try {
await mmkvStorage.setItem(SPEED_SETTINGS_KEY, JSON.stringify({
holdToSpeedEnabled,
holdToSpeedValue
}));
} catch (e) { }
};
saveSettings();
}, [holdToSpeedEnabled, holdToSpeedValue]);
const activateSpeedBoost = useCallback(() => {
if (!holdToSpeedEnabled || isSpeedBoosted || playbackSpeed === holdToSpeedValue) return;
setOriginalSpeed(playbackSpeed);
setPlaybackSpeed(holdToSpeedValue);
setIsSpeedBoosted(true);
setShowSpeedActivatedOverlay(true);
Animated.timing(speedActivatedOverlayOpacity, {
toValue: 1,
duration: 200,
useNativeDriver: true
}).start();
setTimeout(() => {
Animated.timing(speedActivatedOverlayOpacity, {
toValue: 0,
duration: 300,
useNativeDriver: true
}).start(() => setShowSpeedActivatedOverlay(false));
}, 2000);
}, [holdToSpeedEnabled, isSpeedBoosted, playbackSpeed, holdToSpeedValue]);
const deactivateSpeedBoost = useCallback(() => {
if (isSpeedBoosted) {
setPlaybackSpeed(originalSpeed);
setIsSpeedBoosted(false);
Animated.timing(speedActivatedOverlayOpacity, { toValue: 0, duration: 100, useNativeDriver: true }).start();
}
}, [isSpeedBoosted, originalSpeed]);
return {
playbackSpeed,
setPlaybackSpeed,
holdToSpeedEnabled,
setHoldToSpeedEnabled,
holdToSpeedValue,
setHoldToSpeedValue,
isSpeedBoosted,
activateSpeedBoost,
deactivateSpeedBoost,
showSpeedActivatedOverlay,
speedActivatedOverlayOpacity
};
};

View file

@ -0,0 +1,120 @@
import { useState, useEffect, useRef } from 'react';
import { storageService } from '../../../../services/storageService';
import { logger } from '../../../../utils/logger';
import { useSettings } from '../../../../hooks/useSettings';
export const useWatchProgress = (
id: string | undefined,
type: string | undefined,
episodeId: string | undefined,
currentTime: number,
duration: number,
paused: boolean,
traktAutosync: any,
seekToTime: (time: number) => void
) => {
const [resumePosition, setResumePosition] = useState<number | null>(null);
const [savedDuration, setSavedDuration] = useState<number | null>(null);
const [initialPosition, setInitialPosition] = useState<number | null>(null);
const [showResumeOverlay, setShowResumeOverlay] = useState(false);
const [progressSaveInterval, setProgressSaveInterval] = useState<NodeJS.Timeout | null>(null);
const { settings: appSettings } = useSettings();
const initialSeekTargetRef = useRef<number | null>(null);
// Values refs for unmount cleanup
const currentTimeRef = useRef(currentTime);
const durationRef = useRef(duration);
useEffect(() => {
currentTimeRef.current = currentTime;
}, [currentTime]);
useEffect(() => {
durationRef.current = duration;
}, [duration]);
// Load Watch Progress
useEffect(() => {
const loadWatchProgress = async () => {
if (id && type) {
try {
const savedProgress = await storageService.getWatchProgress(id, type, episodeId);
if (savedProgress) {
const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100;
if (progressPercent < 85) {
setResumePosition(savedProgress.currentTime);
setSavedDuration(savedProgress.duration);
if (appSettings.alwaysResume) {
setInitialPosition(savedProgress.currentTime);
initialSeekTargetRef.current = savedProgress.currentTime;
seekToTime(savedProgress.currentTime);
} else {
setShowResumeOverlay(true);
}
}
}
} catch (error) {
logger.error('[useWatchProgress] Error loading watch progress:', error);
}
}
};
loadWatchProgress();
}, [id, type, episodeId, appSettings.alwaysResume]);
const saveWatchProgress = async () => {
if (id && type && currentTimeRef.current > 0 && durationRef.current > 0) {
const progress = {
currentTime: currentTimeRef.current,
duration: durationRef.current,
lastUpdated: Date.now()
};
try {
await storageService.setWatchProgress(id, type, progress, episodeId);
await traktAutosync.handleProgressUpdate(currentTimeRef.current, durationRef.current);
} catch (error) {
logger.error('[useWatchProgress] Error saving watch progress:', error);
}
}
};
// Save Interval
useEffect(() => {
if (id && type && !paused && duration > 0) {
if (progressSaveInterval) clearInterval(progressSaveInterval);
const interval = setInterval(() => {
saveWatchProgress();
}, 10000);
setProgressSaveInterval(interval);
return () => {
clearInterval(interval);
setProgressSaveInterval(null);
};
}
}, [id, type, paused, currentTime, duration]);
// Unmount Save
useEffect(() => {
return () => {
if (id && type && durationRef.current > 0) {
saveWatchProgress();
traktAutosync.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'unmount');
}
};
}, [id, type]);
return {
resumePosition,
savedDuration,
initialPosition,
setInitialPosition,
showResumeOverlay,
setShowResumeOverlay,
saveWatchProgress,
initialSeekTargetRef
};
};

View file

@ -110,3 +110,5 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
</View>
);
};
export default AudioTrackModal;

View file

@ -374,3 +374,5 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
</View>
);
};
export default EpisodeStreamsModal;

View file

@ -170,3 +170,5 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
</View>
);
};
export default EpisodesModal;

View file

@ -0,0 +1,146 @@
import React from 'react';
import * as ExpoClipboard from 'expo-clipboard';
import { View, Text, TouchableOpacity, StyleSheet, useWindowDimensions } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import Animated, {
FadeIn,
FadeOut,
ZoomIn,
ZoomOut,
} from 'react-native-reanimated';
interface ErrorModalProps {
showErrorModal: boolean;
setShowErrorModal: (show: boolean) => void;
errorDetails: string;
onDismiss?: () => void;
}
export const ErrorModal: React.FC<ErrorModalProps> = ({
showErrorModal,
setShowErrorModal,
errorDetails,
onDismiss,
}) => {
const [copied, setCopied] = React.useState(false);
const { width } = useWindowDimensions();
const MODAL_WIDTH = Math.min(width * 0.8, 400);
const handleClose = () => {
setShowErrorModal(false);
if (onDismiss) {
onDismiss();
}
};
const handleCopy = async () => {
await ExpoClipboard.setStringAsync(errorDetails);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (!showErrorModal) return null;
return (
<View style={[StyleSheet.absoluteFill, { zIndex: 99999, justifyContent: 'center', alignItems: 'center' }]}>
<TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={handleClose}>
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.7)' }} />
</TouchableOpacity>
<Animated.View
entering={FadeIn.duration(300)}
exiting={FadeOut.duration(200)}
style={{
width: MODAL_WIDTH,
backgroundColor: '#1a1a1a',
borderRadius: 20,
padding: 24,
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
}}
>
<View style={{
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(239, 68, 68, 0.1)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16
}}>
<MaterialIcons name="error-outline" size={32} color="#EF4444" />
</View>
<Text style={{
color: 'white',
fontSize: 20,
fontWeight: '700',
marginBottom: 8,
textAlign: 'center'
}}>
Playback Error
</Text>
<Text
numberOfLines={3}
ellipsizeMode="tail"
style={{
color: 'rgba(255,255,255,0.7)',
fontSize: 15,
textAlign: 'center',
marginBottom: 16,
lineHeight: 22
}}
>
{errorDetails || 'An unknown error occurred during playback.'}
</Text>
<TouchableOpacity
onPress={handleCopy}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 8,
marginBottom: 24,
opacity: 0.8
}}
>
<MaterialIcons
name={copied ? "check" : "content-copy"}
size={16}
color="rgba(255,255,255,0.6)"
style={{ marginRight: 6 }}
/>
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 13, fontWeight: '500' }}>
{copied ? 'Copied to clipboard' : 'Copy error details'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={{
backgroundColor: 'white',
paddingVertical: 12,
paddingHorizontal: 32,
borderRadius: 12,
width: '100%',
alignItems: 'center'
}}
onPress={handleClose}
activeOpacity={0.9}
>
<Text style={{
color: 'black',
fontSize: 16,
fontWeight: '700'
}}>
Dismiss
</Text>
</TouchableOpacity>
</Animated.View>
</View>
);
};
export default ErrorModal;

View file

@ -276,4 +276,6 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
</Animated.View>
</View>
);
};
};
export default SourcesModal;

View file

@ -140,8 +140,8 @@ export const styles = StyleSheet.create({
topButton: {
padding: 8,
},
/* CloudStream Style - Center Controls */
controls: {
position: 'absolute',
@ -156,7 +156,7 @@ export const styles = StyleSheet.create({
gap: controlsGap,
zIndex: 1000,
},
/* CloudStream Style - Seek Buttons */
seekButtonContainer: {
alignItems: 'center',
@ -187,7 +187,7 @@ export const styles = StyleSheet.create({
textAlign: 'center',
marginLeft: -7,
},
/* CloudStream Style - Play Button */
playButton: {
alignItems: 'center',
@ -202,7 +202,7 @@ export const styles = StyleSheet.create({
color: '#FFFFFF',
opacity: 1,
},
/* CloudStream Style - Arc Animations */
arcContainer: {
position: 'absolute',
@ -233,9 +233,65 @@ export const styles = StyleSheet.create({
position: 'absolute',
backgroundColor: 'rgba(255, 255, 255, 0.3)',
},
speedActivatedOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
zIndex: 1500,
pointerEvents: 'none',
},
speedActivatedContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.7)',
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 30,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
speedActivatedText: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: 'bold',
},
gestureIndicatorContainer: {
position: 'absolute',
top: 40,
alignSelf: 'center',
left: 0,
right: 0,
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000,
},
gestureIndicatorPill: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 24,
paddingVertical: 8,
paddingHorizontal: 14,
gap: 8,
},
iconWrapper: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.15)',
},
gestureText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
minWidth: 48,
textAlign: 'center',
},
bottomControls: {
gap: 12,
@ -1162,4 +1218,4 @@ export const styles = StyleSheet.create({
fontSize: skipTextFont,
marginTop: 2,
},
});
});

View file

@ -61,9 +61,9 @@ export interface TextTrack {
type?: string | null; // Adjusting type based on linter error
}
// Define the possible resize modes - force to stretch for absolute full screen
export type ResizeModeType = 'contain' | 'cover' | 'none';
export const resizeModes: ResizeModeType[] = ['cover']; // Force cover mode for absolute full screen
// Define the possible resize modes
export type ResizeModeType = 'contain' | 'cover' | 'stretch' | 'none';
export const resizeModes: ResizeModeType[] = ['cover', 'contain', 'stretch'];
// Add VLC specific interface for their event structure
export interface VlcMediaEvent {
@ -71,8 +71,8 @@ export interface VlcMediaEvent {
duration: number;
bufferTime?: number;
isBuffering?: boolean;
audioTracks?: Array<{id: number, name: string, language?: string}>;
textTracks?: Array<{id: number, name: string, language?: string}>;
audioTracks?: Array<{ id: number, name: string, language?: string }>;
textTracks?: Array<{ id: number, name: string, language?: string }>;
selectedAudioTrack?: number;
selectedTextTrack?: number;
}

View file

@ -200,4 +200,35 @@ export const detectRTL = (text: string): boolean => {
// Consider RTL if at least 30% of non-whitespace characters are RTL
// This handles mixed-language subtitles (e.g., Arabic with English numbers)
return rtlCount / nonWhitespace.length >= 0.3;
};
// Check if a URL is an HLS stream
export const isHlsStream = (url: string | undefined): boolean => {
if (!url) return false;
return url.includes('.m3u8') || url.includes('.m3u');
};
// Process URL for VLC to handle specific protocol requirements
export const processUrlForVLC = (url: string | undefined): string => {
if (!url) return '';
// Some HLS streams need to be passed with specific protocols for VLC
if (url.startsWith('https://') && isHlsStream(url)) {
// Standard HTTPS is usually fine, but some implementations might prefer http
return url;
}
return url;
};
// Default headers for Android requests
export const defaultAndroidHeaders = {
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; Mobile; rv:89.0) Gecko/89.0 Firefox/89.0',
'Accept': '*/*'
};
// Get specific headers for HLS streams
export const getHlsHeaders = () => {
return {
...defaultAndroidHeaders,
'Accept': 'application/x-mpegURL, application/vnd.apple.mpegurl, application/json, text/plain',
};
};

View file

@ -0,0 +1,386 @@
import React, { useEffect, useState, useCallback } from 'react';
import { View, StyleSheet, Text, TouchableOpacity, Image, Linking, useWindowDimensions } from 'react-native';
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown, SlideInUp, SlideOutUp } from 'react-native-reanimated';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons';
import { campaignService, Campaign, CampaignAction } from '../../services/campaignService';
import { PosterModal } from './PosterModal';
import { useNavigation } from '@react-navigation/native';
import { useAccount } from '../../contexts/AccountContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface BannerProps {
campaign: Campaign;
onDismiss: () => void;
onAction: (action: CampaignAction) => void;
}
const BannerCampaign: React.FC<BannerProps> = ({ campaign, onDismiss, onAction }) => {
const insets = useSafeAreaInsets();
const { width } = useWindowDimensions();
const { content } = campaign;
const isTablet = width >= 768;
const bannerMaxWidth = isTablet ? 600 : width - 24;
const handlePress = () => {
if (content.primaryAction) {
onAction(content.primaryAction);
if (content.primaryAction.type === 'dismiss') {
onDismiss();
} else if (content.primaryAction.type === 'link' && content.primaryAction.value) {
Linking.openURL(content.primaryAction.value);
onDismiss();
}
}
};
return (
<Animated.View
entering={SlideInUp.duration(300)}
exiting={SlideOutUp.duration(250)}
style={[styles.bannerContainer, { paddingTop: insets.top + 8 }]}
>
<TouchableOpacity
style={[
styles.banner,
{
backgroundColor: content.backgroundColor || '#1a1a1a',
maxWidth: bannerMaxWidth,
alignSelf: 'center',
width: '100%',
}
]}
onPress={handlePress}
activeOpacity={0.9}
>
{content.imageUrl && (
<Image
source={{ uri: content.imageUrl }}
style={[styles.bannerImage, { width: isTablet ? 52 : 44, height: isTablet ? 52 : 44 }]}
/>
)}
<View style={styles.bannerContent}>
{content.title && (
<Text style={[styles.bannerTitle, { color: content.textColor || '#fff', fontSize: isTablet ? 16 : 14 }]}>
{content.title}
</Text>
)}
{content.message && (
<Text style={[styles.bannerMessage, { color: content.textColor || '#fff', fontSize: isTablet ? 14 : 12 }]} numberOfLines={2}>
{content.message}
</Text>
)}
</View>
{content.primaryAction?.label && (
<View style={[styles.bannerCta, { backgroundColor: content.textColor || '#fff', paddingHorizontal: isTablet ? 16 : 12 }]}>
<Text style={[styles.bannerCtaText, { color: content.backgroundColor || '#1a1a1a', fontSize: isTablet ? 14 : 12 }]}>
{content.primaryAction.label}
</Text>
</View>
)}
<TouchableOpacity style={styles.bannerClose} onPress={onDismiss}>
<Ionicons name="close" size={isTablet ? 22 : 18} color={content.closeButtonColor || '#fff'} />
</TouchableOpacity>
</TouchableOpacity>
</Animated.View>
);
};
interface BottomSheetProps {
campaign: Campaign;
onDismiss: () => void;
onAction: (action: CampaignAction) => void;
}
const BottomSheetCampaign: React.FC<BottomSheetProps> = ({ campaign, onDismiss, onAction }) => {
const insets = useSafeAreaInsets();
const { width, height } = useWindowDimensions();
const { content } = campaign;
const isTablet = width >= 768;
const isLandscape = width > height;
const sheetMaxWidth = isTablet ? 500 : width;
const imageMaxHeight = isLandscape ? height * 0.35 : height * 0.3;
const handlePrimaryAction = () => {
if (content.primaryAction) {
onAction(content.primaryAction);
if (content.primaryAction.type === 'dismiss') {
onDismiss();
} else if (content.primaryAction.type === 'link' && content.primaryAction.value) {
Linking.openURL(content.primaryAction.value);
onDismiss();
}
}
};
return (
<View style={StyleSheet.absoluteFill}>
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={StyleSheet.absoluteFill}
>
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onDismiss}>
<BlurView intensity={20} tint="dark" style={StyleSheet.absoluteFill} />
</TouchableOpacity>
</Animated.View>
<Animated.View
entering={SlideInDown.duration(300)}
exiting={SlideOutDown.duration(250)}
style={[
styles.bottomSheet,
{
paddingBottom: insets.bottom + 24,
...(isTablet && {
left: (width - sheetMaxWidth) / 2,
right: (width - sheetMaxWidth) / 2,
borderRadius: 20,
marginBottom: 20,
}),
}
]}
>
<View style={styles.bottomSheetHandle} />
<TouchableOpacity style={styles.bottomSheetClose} onPress={onDismiss}>
<Ionicons name="close" size={isTablet ? 26 : 22} color={content.closeButtonColor || '#fff'} />
</TouchableOpacity>
{content.imageUrl && (
<Image
source={{ uri: content.imageUrl }}
style={[
styles.bottomSheetImage,
{
aspectRatio: content.aspectRatio || 1.5,
maxHeight: imageMaxHeight,
}
]}
resizeMode="cover"
/>
)}
<View style={styles.bottomSheetContent}>
{content.title && (
<Text style={[styles.bottomSheetTitle, { color: content.textColor || '#fff', fontSize: isTablet ? 24 : 20 }]}>
{content.title}
</Text>
)}
{content.message && (
<Text style={[styles.bottomSheetMessage, { color: content.textColor || '#fff', fontSize: isTablet ? 16 : 14 }]}>
{content.message}
</Text>
)}
</View>
{content.primaryAction && (
<TouchableOpacity
style={[styles.bottomSheetButton, { backgroundColor: content.textColor || '#fff', paddingVertical: isTablet ? 16 : 14 }]}
onPress={handlePrimaryAction}
activeOpacity={0.8}
>
<Text style={[styles.bottomSheetButtonText, { color: content.backgroundColor || '#1a1a1a', fontSize: isTablet ? 17 : 15 }]}>
{content.primaryAction.label}
</Text>
</TouchableOpacity>
)}
</Animated.View>
</View>
);
};
export const CampaignManager: React.FC = () => {
const [activeCampaign, setActiveCampaign] = useState<Campaign | null>(null);
const [isVisible, setIsVisible] = useState(false);
const navigation = useNavigation();
const { user } = useAccount();
const checkForCampaigns = useCallback(async () => {
try {
console.log('[CampaignManager] Checking for campaigns...');
await new Promise(resolve => setTimeout(resolve, 1500));
const campaign = await campaignService.getActiveCampaign();
console.log('[CampaignManager] Got campaign:', campaign?.id, campaign?.type);
if (campaign) {
setActiveCampaign(campaign);
setIsVisible(true);
campaignService.recordImpression(campaign.id, campaign.rules.showOncePerUser);
}
} catch (error) {
console.warn('[CampaignManager] Failed to check campaigns', error);
}
}, []);
useEffect(() => {
checkForCampaigns();
}, [checkForCampaigns]);
const handleDismiss = useCallback(() => {
setIsVisible(false);
setTimeout(() => {
const nextCampaign = campaignService.getNextCampaign();
console.log('[CampaignManager] Next campaign:', nextCampaign?.id, nextCampaign?.type);
if (nextCampaign) {
setActiveCampaign(nextCampaign);
setIsVisible(true);
campaignService.recordImpression(nextCampaign.id, nextCampaign.rules.showOncePerUser);
} else {
setActiveCampaign(null);
}
}, 350);
}, []);
const handleAction = useCallback((action: CampaignAction) => {
console.log('[CampaignManager] Action:', action);
if (action.type === 'navigate' && action.value) {
handleDismiss();
setTimeout(() => {
try {
(navigation as any).navigate(action.value);
} catch (error) {
console.warn('[CampaignManager] Navigation failed:', error);
}
}, 400);
} else if (action.type === 'link' && action.value) {
Linking.openURL(action.value);
}
}, [navigation, handleDismiss]);
if (!activeCampaign || !isVisible) return null;
return (
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
{activeCampaign.type === 'poster_modal' && (
<PosterModal
campaign={activeCampaign}
onDismiss={handleDismiss}
onAction={handleAction}
/>
)}
{activeCampaign.type === 'banner' && (
<BannerCampaign
campaign={activeCampaign}
onDismiss={handleDismiss}
onAction={handleAction}
/>
)}
{activeCampaign.type === 'bottom_sheet' && (
<BottomSheetCampaign
campaign={activeCampaign}
onDismiss={handleDismiss}
onAction={handleAction}
/>
)}
</View>
);
};
const styles = StyleSheet.create({
bannerContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
paddingHorizontal: 12,
},
banner: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
borderRadius: 14,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 8,
},
bannerImage: {
borderRadius: 10,
marginRight: 12,
},
bannerContent: {
flex: 1,
},
bannerTitle: {
fontWeight: '600',
marginBottom: 2,
},
bannerMessage: {
opacity: 0.8,
},
bannerCta: {
paddingVertical: 8,
borderRadius: 16,
marginLeft: 10,
},
bannerCtaText: {
fontWeight: '600',
},
bannerClose: {
padding: 6,
marginLeft: 8,
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
},
bottomSheet: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#1a1a1a',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingHorizontal: 24,
paddingTop: 14,
},
bottomSheetHandle: {
width: 40,
height: 4,
backgroundColor: 'rgba(255,255,255,0.2)',
borderRadius: 2,
alignSelf: 'center',
marginBottom: 18,
},
bottomSheetClose: {
position: 'absolute',
top: 18,
right: 18,
zIndex: 10,
padding: 4,
},
bottomSheetImage: {
width: '100%',
borderRadius: 12,
marginBottom: 18,
},
bottomSheetContent: {
marginBottom: 22,
},
bottomSheetTitle: {
fontWeight: '600',
marginBottom: 10,
textAlign: 'center',
},
bottomSheetMessage: {
opacity: 0.8,
textAlign: 'center',
lineHeight: 22,
},
bottomSheetButton: {
borderRadius: 26,
alignItems: 'center',
},
bottomSheetButtonText: {
fontWeight: '600',
},
});

View file

@ -0,0 +1,241 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
Linking,
useWindowDimensions,
} from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Campaign } from '../../services/campaignService';
interface PosterModalProps {
campaign: Campaign;
onDismiss: () => void;
onAction: (action: any) => void;
}
export const PosterModal: React.FC<PosterModalProps> = ({
campaign,
onDismiss,
onAction,
}) => {
const insets = useSafeAreaInsets();
const { width, height } = useWindowDimensions();
const { content } = campaign;
const isPosterOnly = !content.title && !content.message;
const isTablet = width >= 768;
const isLandscape = width > height;
const modalWidth = isTablet
? Math.min(width * 0.5, 420)
: isLandscape
? Math.min(width * 0.45, 360)
: Math.min(width * 0.85, 340);
const maxImageHeight = isLandscape ? height * 0.6 : height * 0.5;
const handleAction = () => {
if (content.primaryAction) {
if (content.primaryAction.type === 'link' && content.primaryAction.value) {
Linking.openURL(content.primaryAction.value);
onAction(content.primaryAction);
onDismiss();
} else if (content.primaryAction.type === 'dismiss') {
onDismiss();
} else {
onAction(content.primaryAction);
}
}
};
return (
<View style={StyleSheet.absoluteFill}>
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={styles.backdrop}
>
<BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} />
<TouchableOpacity
style={StyleSheet.absoluteFill}
activeOpacity={1}
onPress={onDismiss}
/>
</Animated.View>
<Animated.View
entering={FadeIn.duration(250)}
exiting={FadeOut.duration(200)}
style={[
styles.modalContainer,
{ paddingTop: insets.top + 20, paddingBottom: insets.bottom + 20 }
]}
pointerEvents="box-none"
>
<View style={[styles.contentWrapper, { width: modalWidth }]}>
<TouchableOpacity
style={styles.closeButton}
onPress={onDismiss}
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
>
<View style={styles.closeButtonBg}>
<Ionicons
name="close"
size={isTablet ? 24 : 20}
color={content.closeButtonColor || '#fff'}
/>
</View>
</TouchableOpacity>
{content.imageUrl && (
<View style={[
styles.imageContainer,
{
aspectRatio: content.aspectRatio || 0.7,
maxHeight: maxImageHeight,
}
]}>
<Image
source={{ uri: content.imageUrl }}
style={styles.image}
resizeMode="cover"
/>
</View>
)}
{!isPosterOnly && (
<View style={[
styles.textContainer,
{
backgroundColor: content.backgroundColor || '#1a1a1a',
padding: isTablet ? 24 : 20,
}
]}>
{content.title && (
<Text style={[
styles.title,
{
color: content.textColor || '#fff',
fontSize: isTablet ? 24 : 20,
}
]}>
{content.title}
</Text>
)}
{content.message && (
<Text style={[
styles.message,
{
color: content.textColor || '#fff',
fontSize: isTablet ? 16 : 14,
}
]}>
{content.message}
</Text>
)}
</View>
)}
{content.primaryAction && (
<TouchableOpacity
style={[
styles.actionButton,
{
backgroundColor: content.textColor || '#fff',
marginTop: isPosterOnly ? 16 : 0,
paddingVertical: isTablet ? 16 : 14,
minWidth: isTablet ? 220 : 180,
}
]}
onPress={handleAction}
activeOpacity={0.8}
>
<Text style={[
styles.actionButtonText,
{
color: content.backgroundColor || '#1a1a1a',
fontSize: isTablet ? 17 : 15,
}
]}>
{content.primaryAction.label}
</Text>
</TouchableOpacity>
)}
</View>
</Animated.View>
</View>
);
};
const styles = StyleSheet.create({
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.6)',
zIndex: 998,
},
modalContainer: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
},
contentWrapper: {
alignItems: 'center',
},
closeButton: {
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
},
closeButtonBg: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(0,0,0,0.5)',
alignItems: 'center',
justifyContent: 'center',
},
imageContainer: {
width: '100%',
borderRadius: 14,
overflow: 'hidden',
backgroundColor: '#222',
},
image: {
width: '100%',
height: '100%',
},
textContainer: {
width: '100%',
borderBottomLeftRadius: 14,
borderBottomRightRadius: 14,
marginTop: -2,
},
title: {
fontWeight: '600',
marginBottom: 6,
textAlign: 'center',
},
message: {
lineHeight: 22,
textAlign: 'center',
opacity: 0.85,
},
actionButton: {
paddingHorizontal: 32,
borderRadius: 24,
marginTop: 16,
alignItems: 'center',
},
actionButtonText: {
fontWeight: '600',
},
});

View file

@ -88,6 +88,8 @@ export interface AppSettings {
streamCacheTTL: number; // Stream cache duration in milliseconds (default: 1 hour)
enableStreamsBackdrop: boolean; // Enable blurred backdrop background on StreamsScreen mobile
useExternalPlayerForDownloads: boolean; // Enable/disable external player for downloaded content
// Android MPV player settings
useHardwareDecoding: boolean; // Enable hardware decoding for MPV player on Android (default: false for software decoding)
}
export const DEFAULT_SETTINGS: AppSettings = {
@ -149,6 +151,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
openMetadataScreenWhenCacheDisabled: true, // Default to StreamsScreen when cache disabled
streamCacheTTL: 60 * 60 * 1000, // Default: 1 hour in milliseconds
enableStreamsBackdrop: true, // Enable by default (new behavior)
// Android MPV player settings
useHardwareDecoding: false, // Default to software decoding (more compatible)
};
const SETTINGS_STORAGE_KEY = 'app_settings';

View file

@ -125,7 +125,6 @@ export type RootStackParamList = {
streamProvider?: string;
streamName?: string;
headers?: { [key: string]: string };
forceVlc?: boolean;
id?: string;
type?: string;
episodeId?: string;
@ -146,7 +145,6 @@ export type RootStackParamList = {
streamProvider?: string;
streamName?: string;
headers?: { [key: string]: string };
forceVlc?: boolean;
id?: string;
type?: string;
episodeId?: string;

View file

@ -525,7 +525,6 @@ const DownloadsScreen: React.FC = () => {
streamProvider: 'Downloads',
streamName: item.providerName || 'Offline',
headers: undefined,
forceVlc: Platform.OS === 'android' ? isMkv : false,
id: item.contentId, // Use contentId (base ID) instead of compound id for progress tracking
type: item.type,
episodeId: episodeId, // Pass episodeId for series progress tracking

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import {
View,
Text,
@ -14,6 +14,7 @@ import { useNavigation } from '@react-navigation/native';
import { useSettings, AppSettings } from '../hooks/useSettings';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { useTheme } from '../contexts/ThemeContext';
import CustomAlert from '../components/CustomAlert';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
@ -95,6 +96,17 @@ const PlayerSettingsScreen: React.FC = () => {
const { currentTheme } = useTheme();
const navigation = useNavigation();
// CustomAlert state
const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState('');
const openAlert = (title: string, message: string) => {
setAlertTitle(title);
setAlertMessage(message);
setAlertVisible(true);
};
const playerOptions = [
{
id: 'internal',
@ -323,6 +335,53 @@ const PlayerSettingsScreen: React.FC = () => {
</View>
</View>
{/* Hardware Decoding for Android Internal Player */}
{Platform.OS === 'android' && !settings.useExternalPlayer && (
<View style={[styles.settingItem, styles.settingItemBorder, { borderTopColor: 'rgba(255,255,255,0.08)' }]}>
<View style={styles.settingContent}>
<View style={[
styles.settingIconContainer,
{ backgroundColor: 'rgba(255,255,255,0.1)' }
]}>
<MaterialIcons
name="memory"
size={20}
color={currentTheme.colors.primary}
/>
</View>
<View style={styles.settingText}>
<Text
style={[
styles.settingTitle,
{ color: currentTheme.colors.text },
]}
>
Hardware Decoding
</Text>
<Text
style={[
styles.settingDescription,
{ color: currentTheme.colors.textMuted },
]}
>
Use GPU for video decoding. May improve performance but can cause issues on some devices.
</Text>
</View>
<Switch
value={settings.useHardwareDecoding}
onValueChange={(value) => {
updateSetting('useHardwareDecoding', value);
openAlert(
'Restart Required',
'Please restart the app for the decoding change to take effect.'
);
}}
thumbColor={settings.useHardwareDecoding ? currentTheme.colors.primary : undefined}
/>
</View>
</View>
)}
{/* External Player for Downloads */}
{((Platform.OS === 'android' && settings.useExternalPlayer) ||
(Platform.OS === 'ios' && settings.preferredPlayer !== 'internal')) && (
@ -367,6 +426,13 @@ const PlayerSettingsScreen: React.FC = () => {
</View>
</View>
</ScrollView>
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
onClose={() => setAlertVisible(false)}
/>
</SafeAreaView>
);
};

View file

@ -39,6 +39,7 @@ import PluginIcon from '../components/icons/PluginIcon';
import TraktIcon from '../components/icons/TraktIcon';
import TMDBIcon from '../components/icons/TMDBIcon';
import MDBListIcon from '../components/icons/MDBListIcon';
import { campaignService } from '../services/campaignService';
const { width, height } = Dimensions.get('window');
const isTablet = width >= 768;
@ -801,6 +802,17 @@ const SettingsScreen: React.FC = () => {
renderControl={ChevronRight}
isTablet={isTablet}
/>
<SettingItem
title="Reset Campaigns"
description="Clear campaign impressions"
icon="refresh-cw"
onPress={async () => {
await campaignService.resetCampaigns();
openAlert('Success', 'Campaign history reset. Restart app to see posters again.');
}}
renderControl={ChevronRight}
isTablet={isTablet}
/>
<SettingItem
title="Clear All Data"
icon="trash-2"

View file

@ -821,7 +821,7 @@ export const StreamsScreen = () => {
fetchIMDbRatings();
}, [type, id, currentEpisode?.season_number, currentEpisode?.episode_number]);
const navigateToPlayer = useCallback(async (stream: Stream, options?: { forceVlc?: boolean; headers?: Record<string, string> }) => {
const navigateToPlayer = useCallback(async (stream: Stream, options?: { headers?: Record<string, string> }) => {
// Filter headers for Vidrock - only send essential headers
// Filter headers for Vidrock - only send essential headers
// Filter headers for Vidrock - only send essential headers
@ -859,9 +859,6 @@ export const StreamsScreen = () => {
const streamName = stream.name || stream.title || 'Unnamed Stream';
const streamProvider = stream.addonId || stream.addonName || stream.name;
// Do NOT pre-force VLC. Let ExoPlayer try first; fallback occurs on decoder error in the player.
let forceVlc = !!options?.forceVlc;
// Save stream to cache for future use
try {
const episodeId = (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined;
@ -922,8 +919,6 @@ export const StreamsScreen = () => {
streamName: streamName,
// Use filtered headers for Vidrock compatibility
headers: finalHeaders,
// Android will use this to choose VLC path; iOS ignores
forceVlc,
id,
type,
episodeId: (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined,

View file

@ -0,0 +1,188 @@
import { mmkvStorage } from './mmkvStorage';
import { Platform } from 'react-native';
const DEV_URL = 'https://campaign.nuvioapp.space/';
const PROD_URL = process.env.EXPO_PUBLIC_CAMPAIGN_API_URL || '';
const CAMPAIGN_API_URL = __DEV__ ? DEV_URL : PROD_URL;
export type CampaignAction = {
type: 'link' | 'navigate' | 'dismiss';
value?: string;
label: string;
style?: 'primary' | 'secondary' | 'outline';
};
export type CampaignContent = {
title?: string;
message?: string;
imageUrl?: string;
backgroundColor?: string;
textColor?: string;
closeButtonColor?: string;
primaryAction?: CampaignAction | null;
secondaryAction?: CampaignAction | null;
aspectRatio?: number;
};
export type CampaignRules = {
startDate?: string;
endDate?: string;
maxImpressions?: number | null;
minVersion?: string;
maxVersion?: string;
platforms?: string[];
priority: number;
showOncePerSession?: boolean;
showOncePerUser?: boolean;
};
export type Campaign = {
id: string;
type: 'poster_modal' | 'banner' | 'bottom_sheet';
content: CampaignContent;
rules: CampaignRules;
};
class CampaignService {
private sessionImpressions: Set<string>;
private campaignQueue: Campaign[] = [];
private currentIndex: number = 0;
private lastFetch: number = 0;
private readonly CACHE_TTL = 5 * 60 * 1000;
constructor() {
this.sessionImpressions = new Set();
}
async getActiveCampaign(): Promise<Campaign | null> {
try {
const now = Date.now();
if (this.campaignQueue.length > 0 && (now - this.lastFetch) < this.CACHE_TTL) {
return this.getNextValidCampaign();
}
const platform = Platform.OS;
const response = await fetch(
`${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`,
{
method: 'GET',
headers: { 'Accept': 'application/json' },
}
);
if (!response.ok) {
console.warn('[CampaignService] Failed to fetch campaigns:', response.status);
return null;
}
const campaigns = await response.json();
if (!campaigns || !Array.isArray(campaigns) || campaigns.length === 0) {
this.campaignQueue = [];
this.currentIndex = 0;
this.lastFetch = now;
return null;
}
campaigns.forEach((campaign: Campaign) => {
if (campaign.content?.imageUrl && campaign.content.imageUrl.startsWith('/')) {
campaign.content.imageUrl = `${CAMPAIGN_API_URL}${campaign.content.imageUrl}`;
}
});
this.campaignQueue = campaigns;
this.currentIndex = 0;
this.lastFetch = now;
return this.getNextValidCampaign();
} catch (error) {
console.warn('[CampaignService] Error fetching campaigns:', error);
return null;
}
}
private getNextValidCampaign(): Campaign | null {
while (this.currentIndex < this.campaignQueue.length) {
const campaign = this.campaignQueue[this.currentIndex];
if (this.isLocallyValid(campaign)) {
return campaign;
}
this.currentIndex++;
}
return null;
}
getNextCampaign(): Campaign | null {
this.currentIndex++;
return this.getNextValidCampaign();
}
private isLocallyValid(campaign: Campaign): boolean {
const { rules } = campaign;
if (rules.showOncePerUser && this.hasSeenCampaign(campaign.id)) {
return false;
}
if (rules.maxImpressions) {
const impressionCount = this.getImpressionCount(campaign.id);
if (impressionCount >= rules.maxImpressions) {
return false;
}
}
if (rules.showOncePerSession && this.sessionImpressions.has(campaign.id)) {
return false;
}
return true;
}
private hasSeenCampaign(campaignId: string): boolean {
return mmkvStorage.getBoolean(`campaign_seen_${campaignId}`) || false;
}
private markCampaignSeen(campaignId: string) {
mmkvStorage.setBoolean(`campaign_seen_${campaignId}`, true);
}
private getImpressionCount(campaignId: string): number {
return mmkvStorage.getNumber(`campaign_impression_${campaignId}`) || 0;
}
recordImpression(campaignId: string, showOncePerUser?: boolean) {
const current = this.getImpressionCount(campaignId);
mmkvStorage.setNumber(`campaign_impression_${campaignId}`, current + 1);
this.sessionImpressions.add(campaignId);
if (showOncePerUser) {
this.markCampaignSeen(campaignId);
}
}
async resetCampaigns() {
this.sessionImpressions.clear();
this.campaignQueue = [];
this.currentIndex = 0;
this.lastFetch = 0;
}
clearCache() {
this.campaignQueue = [];
this.currentIndex = 0;
this.lastFetch = 0;
}
getRemainingCount(): number {
let count = 0;
for (let i = this.currentIndex; i < this.campaignQueue.length; i++) {
if (this.isLocallyValid(this.campaignQueue[i])) {
count++;
}
}
return count;
}
}
export const campaignService = new CampaignService();

View file

@ -9,7 +9,6 @@ import { isMkvStream } from './mkvDetection';
export interface PlayerSelectionOptions {
uri: string;
headers?: Record<string, string>;
forceVlc?: boolean;
platform?: typeof Platform.OS;
}
@ -19,10 +18,9 @@ export interface PlayerSelectionOptions {
export const shouldUseKSPlayer = ({
uri,
headers,
forceVlc = false,
platform = Platform.OS
}: PlayerSelectionOptions): boolean => {
// Android always uses AndroidVideoPlayer (react-native-video)
// Android always uses AndroidVideoPlayer (MPV)
if (platform === 'android') {
return false;
}