This commit is contained in:
tapframe 2025-12-22 21:26:22 +05:30
parent 967b90b98e
commit 19438ff1d5
13 changed files with 746 additions and 164 deletions

View file

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

View file

@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>

View file

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

View file

@ -0,0 +1,249 @@
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
// 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
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 ->
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")
MPVLib.setOptionString("hwdec", "mediacodec,mediacodec-copy")
MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
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")
// 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", MPV_FORMAT_DOUBLE)
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)
}
private fun loadFile(url: String) {
Log.d(TAG, "Loading file: $url")
MPVLib.command(arrayOf("loadfile", url))
}
// Public API
fun setDataSource(url: String) {
if (isMpvInitialized) {
loadFile(url)
} else {
pendingDataSource = url
}
}
fun setPaused(paused: Boolean) {
isPaused = paused
if (isMpvInitialized) {
MPVLib.setPropertyBoolean("pause", paused)
}
}
fun seekTo(positionSeconds: Double) {
if (isMpvInitialized) {
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) {
if (isMpvInitialized) {
if (trackId == -1) {
MPVLib.setPropertyString("sid", "no")
} else {
MPVLib.setPropertyInt("sid", trackId)
}
}
}
// MPVLib.EventObserver implementation
override fun eventProperty(property: String) {
Log.d(TAG, "Property changed: $property")
when (property) {
"track-list" -> {
// Track list updated, could notify JS about available tracks
}
}
}
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") ?: 0.0
onProgressCallback?.invoke(value, duration)
}
"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 -> {
onEndCallback?.invoke()
}
}
}
}

View file

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

View file

@ -0,0 +1,128 @@
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)
}
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")))
.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?) {
when (commandId) {
"seek" -> {
args?.getDouble(0)?.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
}
}

29
package-lock.json generated
View file

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

View file

@ -42,6 +42,7 @@
"expo-auth-session": "~7.0.8",
"expo-blur": "~15.0.7",
"expo-brightness": "~14.0.7",
"expo-clipboard": "~8.0.8",
"expo-crypto": "~15.0.7",
"expo-dev-client": "~6.0.15",
"expo-device": "~8.0.9",
@ -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",

View file

@ -1,5 +1,6 @@
import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react';
import { View, StyleSheet, Platform, Animated } from 'react-native';
import { toast } from '@backpackapp-io/react-native-toast';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
@ -349,8 +350,35 @@ const AndroidVideoPlayer: React.FC = () => {
if (modals.showEpisodeStreamsModal) return;
playerState.setPaused(true);
}}
onError={(err) => {
onError={(err: any) => {
logger.error('Video Error', err);
// Check for decoding errors to switch to VLC
const errorString = err?.errorString || err?.error?.errorString;
const errorCode = err?.errorCode || err?.error?.errorCode;
const causeMessage = err?.error?.cause?.message;
const isDecodingError =
(errorString && errorString.includes('ERROR_CODE_DECODING_FAILED')) ||
errorCode === '24003' ||
(causeMessage && causeMessage.includes('MediaCodecVideoRenderer error'));
if (!useVLC && isDecodingError) {
const toastId = toast.loading('Decoding error. Switching to VLC Player...');
setTimeout(() => toast.dismiss(toastId), 3000);
// We can just show a normal toast or use the existing modal system if we want,
// but checking the file imports, I don't see Toast imported.
// Let's implement the navigation replace.
// Using a simple navigation replace to force VLC
(navigation as any).replace('PlayerAndroid', {
...route.params,
forceVlc: true
});
return;
}
modals.setErrorDetails(JSON.stringify(err));
modals.setShowErrorModal(true);
}}

View file

@ -0,0 +1,117 @@
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;
paused?: boolean;
volume?: number;
rate?: number;
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;
}
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' }]} />
);
}
console.log('[MpvPlayer] Rendering native component with:', {
source: props.source?.substring(0, 50) + '...',
paused: props.paused ?? true,
volume: props.volume ?? 1.0,
rate: props.rate ?? 1.0,
});
const handleLoad = (event: any) => {
console.log('[MpvPlayer] Native onLoad event:', event?.nativeEvent);
props.onLoad?.(event?.nativeEvent);
};
const handleProgress = (event: any) => {
const data = event?.nativeEvent;
if (data && Math.floor(data.currentTime) % 5 === 0) {
console.log('[MpvPlayer] Native onProgress event:', data);
}
props.onProgress?.(data);
};
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);
};
return (
<MpvPlayerNative
ref={nativeRef}
style={[styles.container, props.style]}
source={props.source}
paused={props.paused ?? true}
volume={props.volume ?? 1.0}
rate={props.rate ?? 1.0}
onLoad={handleLoad}
onProgress={handleProgress}
onEnd={handleEnd}
onError={handleError}
/>
);
});
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'black',
},
});
MpvPlayer.displayName = 'MpvPlayer';
export default MpvPlayer;

View file

@ -1,52 +1,18 @@
import React, { forwardRef } from 'react';
import { View, TouchableOpacity, StyleSheet, Platform } from 'react-native';
import Video, { ViewType, VideoRef, ResizeMode } from 'react-native-video';
import VlcVideoPlayer, { VlcPlayerRef } from '../../VlcVideoPlayer';
import React 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';
import { logger } from '../../../../utils/logger';
import { ResizeModeType, SelectedTrack } from '../../utils/playerTypes';
const getVideoResizeMode = (resizeMode: ResizeModeType) => {
switch (resizeMode) {
case 'contain': return 'contain';
case 'cover': return 'cover';
case 'stretch': return 'contain';
case 'none': return 'contain';
default: return 'contain';
}
};
// VLC only supports 'contain' | 'cover' | 'none'
const getVlcResizeMode = (resizeMode: ResizeModeType): 'contain' | 'cover' | 'none' => {
switch (resizeMode) {
case 'contain': return 'contain';
case 'cover': return 'cover';
case 'stretch': return 'cover'; // stretch is not supported, use cover
case 'none': return 'none';
default: return 'contain';
}
};
interface VideoSurfaceProps {
useVLC: boolean;
forceVlcRemount: boolean;
processedStreamUrl: string;
volume: number;
playbackSpeed: number;
zoomScale: number;
resizeMode: ResizeModeType;
paused: boolean;
currentStreamUrl: string;
headers: any;
videoType: any;
vlcSelectedAudioTrack?: number;
vlcSelectedSubtitleTrack?: number;
vlcRestoreTime?: number;
vlcKey: string;
selectedAudioTrack: any;
selectedTextTrack: any;
useCustomSubtitles: boolean;
// Callbacks
toggleControls: () => void;
@ -56,44 +22,45 @@ interface VideoSurfaceProps {
onEnd: () => void;
onError: (err: any) => void;
onBuffer: (buf: any) => void;
onTracksUpdate: (tracks: any) => void;
// Refs
vlcPlayerRef: React.RefObject<VlcPlayerRef>;
videoRef: React.RefObject<VideoRef>;
mpvPlayerRef?: React.RefObject<MpvPlayerRef>;
pinchRef: any;
// Handlers
onPinchGestureEvent: any;
onPinchHandlerStateChange: any;
vlcLoadedRef: React.MutableRefObject<boolean>;
screenDimensions: { width: number, height: number };
customVideoStyles: any;
// Debugging
loadStartAtRef: React.MutableRefObject<number | null>;
firstFrameAtRef: React.MutableRefObject<number | null>;
// Legacy props (kept for compatibility but unused with MPV)
useVLC?: boolean;
forceVlcRemount?: boolean;
headers?: any;
videoType?: any;
vlcSelectedAudioTrack?: number;
vlcSelectedSubtitleTrack?: number;
vlcRestoreTime?: number;
vlcKey?: string;
selectedAudioTrack?: any;
selectedTextTrack?: any;
useCustomSubtitles?: boolean;
onTracksUpdate?: (tracks: any) => void;
vlcPlayerRef?: any;
videoRef?: any;
vlcLoadedRef?: any;
customVideoStyles?: any;
loadStartAtRef?: any;
firstFrameAtRef?: any;
zoomScale?: number;
}
export const VideoSurface: React.FC<VideoSurfaceProps> = ({
useVLC,
forceVlcRemount,
processedStreamUrl,
volume,
playbackSpeed,
zoomScale,
resizeMode,
paused,
currentStreamUrl,
headers,
videoType,
vlcSelectedAudioTrack,
vlcSelectedSubtitleTrack,
vlcRestoreTime,
vlcKey,
selectedAudioTrack,
selectedTextTrack,
useCustomSubtitles,
toggleControls,
onLoad,
onProgress,
@ -101,23 +68,57 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
onEnd,
onError,
onBuffer,
onTracksUpdate,
vlcPlayerRef,
videoRef,
mpvPlayerRef,
pinchRef,
onPinchGestureEvent,
onPinchHandlerStateChange,
vlcLoadedRef,
screenDimensions,
customVideoStyles,
loadStartAtRef,
firstFrameAtRef
}) => {
// Use the actual stream URL
const streamUrl = currentStreamUrl || processedStreamUrl;
const isHlsStream = (url: string) => {
return url.includes('.m3u8') || url.includes('m3u8') ||
url.includes('hls') || url.includes('playlist') ||
(videoType && videoType.toLowerCase() === 'm3u8');
console.log('[VideoSurface] Rendering with:', {
streamUrl: streamUrl?.substring(0, 50) + '...',
paused,
volume,
playbackSpeed,
screenDimensions,
});
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 }) => {
// Log every 5 seconds to avoid spam
if (Math.floor(data.currentTime) % 5 === 0) {
console.log('[VideoSurface] onProgress:', data);
}
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 (
@ -125,96 +126,53 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
width: screenDimensions.width,
height: screenDimensions.height,
}]}>
{/* MPV Player - rendered at the bottom of the z-order */}
<MpvPlayer
ref={mpvPlayerRef}
source={streamUrl}
paused={paused}
volume={volume}
rate={playbackSpeed}
style={localStyles.player}
onLoad={handleLoad}
onProgress={handleProgress}
onEnd={handleEnd}
onError={handleError}
/>
{/* Gesture overlay - transparent, on top of the player */}
<PinchGestureHandler
ref={pinchRef}
onGestureEvent={onPinchGestureEvent}
onHandlerStateChange={onPinchHandlerStateChange}
>
<View style={{
position: 'absolute',
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
}}>
<TouchableOpacity
style={{ flex: 1 }}
activeOpacity={1}
onPress={toggleControls}
>
{useVLC && !forceVlcRemount ? (
<VlcVideoPlayer
ref={vlcPlayerRef}
source={processedStreamUrl}
volume={volume}
playbackSpeed={playbackSpeed}
zoomScale={zoomScale}
resizeMode={getVlcResizeMode(resizeMode)}
onLoad={(data) => {
vlcLoadedRef.current = true;
onLoad(data);
if (!paused && vlcPlayerRef.current) {
setTimeout(() => {
if (vlcPlayerRef.current) {
vlcPlayerRef.current.play();
}
}, 100);
}
}}
onProgress={onProgress}
onSeek={onSeek}
onEnd={onEnd}
onError={onError}
onTracksUpdate={onTracksUpdate}
selectedAudioTrack={vlcSelectedAudioTrack}
selectedSubtitleTrack={vlcSelectedSubtitleTrack}
restoreTime={vlcRestoreTime}
forceRemount={forceVlcRemount}
key={vlcKey}
/>
) : (
<Video
ref={videoRef}
style={[styles.video, customVideoStyles]}
source={{
uri: currentStreamUrl,
headers: headers,
type: isHlsStream(currentStreamUrl) ? 'm3u8' : videoType
}}
paused={paused}
onLoadStart={() => {
loadStartAtRef.current = Date.now();
}}
onProgress={onProgress}
onLoad={onLoad}
onReadyForDisplay={() => {
firstFrameAtRef.current = Date.now();
}}
onSeek={onSeek}
onEnd={onEnd}
onError={onError}
onBuffer={onBuffer}
resizeMode={getVideoResizeMode(resizeMode)}
selectedAudioTrack={selectedAudioTrack || undefined}
selectedTextTrack={useCustomSubtitles ? { type: 'disabled' } as any : (selectedTextTrack >= 0 ? { type: 'index', value: selectedTextTrack } as any : undefined)}
rate={playbackSpeed}
volume={volume}
muted={false}
repeat={false}
playInBackground={false}
playWhenInactive={false}
ignoreSilentSwitch="ignore"
mixWithOthers="inherit"
progressUpdateInterval={500}
disableFocus={true}
allowsExternalPlayback={false}
preventsDisplaySleepDuringVideoPlayback={true}
viewType={Platform.OS === 'android' ? ViewType.SURFACE : undefined}
/>
)}
</TouchableOpacity>
<View style={localStyles.gestureOverlay} pointerEvents="box-only">
<TouchableWithoutFeedback onPress={toggleControls}>
<View style={localStyles.touchArea} />
</TouchableWithoutFeedback>
</View>
</PinchGestureHandler>
</View>
);
};
const localStyles = StyleSheet.create({
player: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
gestureOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
touchArea: {
flex: 1,
backgroundColor: 'transparent',
},
});

View file

@ -1,6 +1,7 @@
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';
@ -18,19 +19,34 @@ export const usePlayerSetup = (
const originalSystemBrightnessModeRef = useRef<number | null>(null);
const isAppBackgrounded = useRef(false);
const enableImmersiveMode = () => {
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 = () => {
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
}
}
};

View file

@ -1,4 +1,5 @@
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, {
@ -21,6 +22,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
errorDetails,
onDismiss,
}) => {
const [copied, setCopied] = React.useState(false);
const { width } = useWindowDimensions();
const MODAL_WIDTH = Math.min(width * 0.8, 400);
@ -31,6 +33,12 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
}
};
const handleCopy = async () => {
await ExpoClipboard.setStringAsync(errorDetails);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (!showErrorModal) return null;
return (
@ -74,16 +82,42 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
Playback Error
</Text>
<Text style={{
color: 'rgba(255,255,255,0.7)',
fontSize: 15,
textAlign: 'center',
marginBottom: 24,
lineHeight: 22
}}>
<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',