mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
Merge branch 'main' into patch-14
This commit is contained in:
commit
74764bbbe0
85 changed files with 8094 additions and 7637 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
App.tsx
2
App.tsx
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', '+')}"
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
480
android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt
Normal file
480
android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
android/app/src/main/java/com/nuvio/app/mpv/MpvPackage.kt
Normal file
16
android/app/src/main/java/com/nuvio/app/mpv/MpvPackage.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
10
app.json
10
app.json
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
1
libmpv-android
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 8c4778b5aad441bb0449a7f9b3d6d827fd3d6a2a
|
||||
1
mpv-android
Submodule
1
mpv-android
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 118cd1ed3d498265e44230e5dbb015bdd59f9dad
|
||||
29
package-lock.json
generated
29
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
427
plugins/mpv-bridge/android/mpv/MPVView.kt
Normal file
427
plugins/mpv-bridge/android/mpv/MPVView.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
plugins/mpv-bridge/android/mpv/MpvPackage.kt
Normal file
16
plugins/mpv-bridge/android/mpv/MpvPackage.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
183
plugins/mpv-bridge/android/mpv/MpvPlayerViewManager.kt
Normal file
183
plugins/mpv-bridge/android/mpv/MpvPlayerViewManager.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
94
plugins/mpv-bridge/withMpvBridge.js
Normal file
94
plugins/mpv-bridge/withMpvBridge.js
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
121
src/components/player/android/MpvPlayer.tsx
Normal file
121
src/components/player/android/MpvPlayer.tsx
Normal 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;
|
||||
194
src/components/player/android/components/GestureControls.tsx
Normal file
194
src/components/player/android/components/GestureControls.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
228
src/components/player/android/components/PauseOverlay.tsx
Normal file
228
src/components/player/android/components/PauseOverlay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
156
src/components/player/android/components/VideoSurface.tsx
Normal file
156
src/components/player/android/components/VideoSurface.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
59
src/components/player/android/hooks/useNextEpisode.ts
Normal file
59
src/components/player/android/hooks/useNextEpisode.ts
Normal 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 };
|
||||
};
|
||||
149
src/components/player/android/hooks/useOpeningAnimation.ts
Normal file
149
src/components/player/android/hooks/useOpeningAnimation.ts
Normal 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
|
||||
};
|
||||
};
|
||||
67
src/components/player/android/hooks/usePlayerControls.ts
Normal file
67
src/components/player/android/hooks/usePlayerControls.ts
Normal 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
|
||||
};
|
||||
};
|
||||
28
src/components/player/android/hooks/usePlayerModals.ts
Normal file
28
src/components/player/android/hooks/usePlayerModals.ts
Normal 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
|
||||
};
|
||||
};
|
||||
123
src/components/player/android/hooks/usePlayerSetup.ts
Normal file
123
src/components/player/android/hooks/usePlayerSetup.ts
Normal 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 };
|
||||
};
|
||||
41
src/components/player/android/hooks/usePlayerState.ts
Normal file
41
src/components/player/android/hooks/usePlayerState.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
43
src/components/player/android/hooks/usePlayerTracks.ts
Normal file
43
src/components/player/android/hooks/usePlayerTracks.ts
Normal 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
|
||||
};
|
||||
};
|
||||
93
src/components/player/android/hooks/useSpeedControl.ts
Normal file
93
src/components/player/android/hooks/useSpeedControl.ts
Normal 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
|
||||
};
|
||||
};
|
||||
125
src/components/player/android/hooks/useWatchProgress.ts
Normal file
125
src/components/player/android/hooks/useWatchProgress.ts
Normal 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
|
||||
};
|
||||
};
|
||||
199
src/components/player/components/GestureControls.tsx
Normal file
199
src/components/player/components/GestureControls.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
259
src/components/player/components/PauseOverlay.tsx
Normal file
259
src/components/player/components/PauseOverlay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
src/components/player/components/SpeedActivatedOverlay.tsx
Normal file
38
src/components/player/components/SpeedActivatedOverlay.tsx
Normal 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;
|
||||
8
src/components/player/components/index.ts
Normal file
8
src/components/player/components/index.ts
Normal 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';
|
||||
21
src/components/player/hooks/index.ts
Normal file
21
src/components/player/hooks/index.ts
Normal 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';
|
||||
110
src/components/player/hooks/useCustomSubtitles.ts
Normal file
110
src/components/player/hooks/useCustomSubtitles.ts
Normal 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
|
||||
};
|
||||
};
|
||||
65
src/components/player/hooks/useNextEpisode.ts
Normal file
65
src/components/player/hooks/useNextEpisode.ts
Normal 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 };
|
||||
};
|
||||
152
src/components/player/hooks/useOpeningAnimation.ts
Normal file
152
src/components/player/hooks/useOpeningAnimation.ts
Normal 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
|
||||
};
|
||||
};
|
||||
81
src/components/player/hooks/usePlayerControls.ts
Normal file
81
src/components/player/hooks/usePlayerControls.ts
Normal 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
|
||||
};
|
||||
};
|
||||
38
src/components/player/hooks/usePlayerModals.ts
Normal file
38
src/components/player/hooks/usePlayerModals.ts
Normal 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
|
||||
};
|
||||
};
|
||||
117
src/components/player/hooks/usePlayerSetup.ts
Normal file
117
src/components/player/hooks/usePlayerSetup.ts
Normal 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 };
|
||||
};
|
||||
88
src/components/player/hooks/usePlayerState.ts
Normal file
88
src/components/player/hooks/usePlayerState.ts
Normal 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
|
||||
};
|
||||
};
|
||||
47
src/components/player/hooks/usePlayerTracks.ts
Normal file
47
src/components/player/hooks/usePlayerTracks.ts
Normal 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
|
||||
};
|
||||
};
|
||||
97
src/components/player/hooks/useSpeedControl.ts
Normal file
97
src/components/player/hooks/useSpeedControl.ts
Normal 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
|
||||
};
|
||||
};
|
||||
333
src/components/player/ios/components/GestureControls.tsx
Normal file
333
src/components/player/ios/components/GestureControls.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
140
src/components/player/ios/components/KSPlayerSurface.tsx
Normal file
140
src/components/player/ios/components/KSPlayerSurface.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
228
src/components/player/ios/components/PauseOverlay.tsx
Normal file
228
src/components/player/ios/components/PauseOverlay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
58
src/components/player/ios/hooks/useCustomSubtitles.ts
Normal file
58
src/components/player/ios/hooks/useCustomSubtitles.ts
Normal 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
|
||||
};
|
||||
};
|
||||
15
src/components/player/ios/hooks/useKSPlayer.ts
Normal file
15
src/components/player/ios/hooks/useKSPlayer.ts
Normal 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
|
||||
};
|
||||
};
|
||||
58
src/components/player/ios/hooks/useNextEpisode.ts
Normal file
58
src/components/player/ios/hooks/useNextEpisode.ts
Normal 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 };
|
||||
};
|
||||
149
src/components/player/ios/hooks/useOpeningAnimation.ts
Normal file
149
src/components/player/ios/hooks/useOpeningAnimation.ts
Normal 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
|
||||
};
|
||||
};
|
||||
63
src/components/player/ios/hooks/usePlayerControls.ts
Normal file
63
src/components/player/ios/hooks/usePlayerControls.ts
Normal 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
|
||||
};
|
||||
};
|
||||
34
src/components/player/ios/hooks/usePlayerModals.ts
Normal file
34
src/components/player/ios/hooks/usePlayerModals.ts
Normal 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
|
||||
};
|
||||
};
|
||||
103
src/components/player/ios/hooks/usePlayerSetup.ts
Normal file
103
src/components/player/ios/hooks/usePlayerSetup.ts
Normal 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 };
|
||||
};
|
||||
83
src/components/player/ios/hooks/usePlayerState.ts
Normal file
83
src/components/player/ios/hooks/usePlayerState.ts
Normal 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
|
||||
};
|
||||
};
|
||||
38
src/components/player/ios/hooks/usePlayerTracks.ts
Normal file
38
src/components/player/ios/hooks/usePlayerTracks.ts
Normal 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
|
||||
};
|
||||
};
|
||||
93
src/components/player/ios/hooks/useSpeedControl.ts
Normal file
93
src/components/player/ios/hooks/useSpeedControl.ts
Normal 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
|
||||
};
|
||||
};
|
||||
120
src/components/player/ios/hooks/useWatchProgress.ts
Normal file
120
src/components/player/ios/hooks/useWatchProgress.ts
Normal 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
|
||||
};
|
||||
};
|
||||
|
|
@ -121,3 +121,5 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
|
|||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioTrackModal;
|
||||
|
|
|
|||
|
|
@ -272,3 +272,5 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
|
|||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default EpisodeStreamsModal;
|
||||
|
|
|
|||
|
|
@ -165,3 +165,5 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
|
|||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default EpisodesModal;
|
||||
|
|
|
|||
146
src/components/player/modals/ErrorModal.tsx
Normal file
146
src/components/player/modals/ErrorModal.tsx
Normal 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;
|
||||
|
|
@ -246,3 +246,5 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
|
|||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SourcesModal;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
};
|
||||
386
src/components/promotions/CampaignManager.tsx
Normal file
386
src/components/promotions/CampaignManager.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
241
src/components/promotions/PosterModal.tsx
Normal file
241
src/components/promotions/PosterModal.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
188
src/services/campaignService.ts
Normal file
188
src/services/campaignService.ts
Normal 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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue