diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt index 88b8a96f..c983ccc9 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt @@ -28,6 +28,7 @@ class MPVView @JvmOverloads constructor( var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null var onEndCallback: (() -> Unit)? = null var onErrorCallback: ((message: String) -> Unit)? = null + var onTracksChangedCallback: ((audioTracks: List>, subtitleTracks: List>) -> Unit)? = null init { surfaceTextureListener = this @@ -89,8 +90,13 @@ class MPVView @JvmOverloads constructor( 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") + + // Hardware decoding - use mediacodec-copy to allow subtitle overlay + // 'mediacodec-copy' copies frames to CPU memory which enables subtitle blending + MPVLib.setOptionString("hwdec", "mediacodec-copy") + MPVLib.setOptionString("hwdec-codecs", "all") + + // Audio output MPVLib.setOptionString("ao", "audiotrack,opensles") // Network caching for streaming @@ -99,6 +105,43 @@ class MPVView @JvmOverloads constructor( MPVLib.setOptionString("cache", "yes") MPVLib.setOptionString("cache-secs", "30") + // 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") @@ -120,6 +163,11 @@ class MPVView @JvmOverloads constructor( 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) { @@ -176,11 +224,52 @@ class MPVView @JvmOverloads constructor( } 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") + } } } } @@ -191,10 +280,58 @@ class MPVView @JvmOverloads constructor( Log.d(TAG, "Property changed: $property") when (property) { "track-list" -> { - // Track list updated, could notify JS about available tracks + // 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>() + val subtitleTracks = mutableListOf>() + + 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)") @@ -244,7 +381,22 @@ class MPVView @JvmOverloads constructor( } } MPV_EVENT_END_FILE -> { - onEndCallback?.invoke() + 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") ?: 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() + } } } } diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt index 45b055eb..db5426ed 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt @@ -58,6 +58,35 @@ class MpvPlayerViewManager( 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 } @@ -72,6 +101,7 @@ class MpvPlayerViewManager( .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() } @@ -128,4 +158,9 @@ class MpvPlayerViewManager( // 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") + } } diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 55c93b12..cb02cabc 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -396,11 +396,45 @@ const AndroidVideoPlayer: React.FC = () => { return; } - modals.setErrorDetails(JSON.stringify(err)); + // Determine the actual error message + let displayError = 'An unknown error occurred'; + + if (typeof err?.error === 'string') { + displayError = err.error; + } else if (err?.error?.errorString) { + displayError = err.error.errorString; + } else if (err?.errorString) { + displayError = err.errorString; + } else if (typeof err === 'string') { + displayError = err; + } else { + displayError = JSON.stringify(err); + } + + modals.setErrorDetails(displayError); modals.setShowErrorModal(true); }} onBuffer={(buf) => playerState.setIsBuffering(buf.isBuffering)} onTracksUpdate={vlcHook.handleVlcTracksUpdate} + onTracksChanged={(data) => { + console.log('[AndroidVideoPlayer] onTracksChanged:', data); + if (data?.audioTracks) { + const formatted = data.audioTracks.map((t: any) => ({ + id: t.id, + name: t.name || `Track ${t.id}`, + language: t.language + })); + tracksHook.setRnVideoAudioTracks(formatted); + } + if (data?.subtitleTracks) { + const formatted = data.subtitleTracks.map((t: any) => ({ + id: t.id, + name: t.name || `Track ${t.id}`, + language: t.language + })); + tracksHook.setRnVideoTextTracks(formatted); + } + }} vlcPlayerRef={vlcHook.vlcPlayerRef} mpvPlayerRef={mpvPlayerRef} videoRef={videoRef} @@ -504,8 +538,15 @@ const AndroidVideoPlayer: React.FC = () => { ksAudioTracks={tracksHook.ksAudioTracks} selectedAudioTrack={tracksHook.computedSelectedAudioTrack} selectAudioTrack={(trackId) => { - useVLC ? vlcHook.selectVlcAudioTrack(trackId) : + if (useVLC) { + vlcHook.selectVlcAudioTrack(trackId); + } else { tracksHook.setSelectedAudioTrack(trackId === null ? null : { type: 'index', value: trackId }); + // Actually tell MPV to switch the audio track + if (trackId !== null && mpvPlayerRef.current) { + mpvPlayerRef.current.setAudioTrack(trackId); + } + } }} /> @@ -527,7 +568,15 @@ const AndroidVideoPlayer: React.FC = () => { fetchAvailableSubtitles={() => { }} // Placeholder loadWyzieSubtitle={() => { }} // Placeholder selectTextTrack={(trackId) => { - useVLC ? vlcHook.selectVlcSubtitleTrack(trackId) : tracksHook.setSelectedTextTrack(trackId); + if (useVLC) { + vlcHook.selectVlcSubtitleTrack(trackId); + } else { + tracksHook.setSelectedTextTrack(trackId); + // Actually tell MPV to switch the subtitle track + if (mpvPlayerRef.current) { + mpvPlayerRef.current.setSubtitleTrack(trackId); + } + } modals.setShowSubtitleModal(false); }} disableCustomSubtitles={() => { }} // Placeholder diff --git a/src/components/player/android/MpvPlayer.tsx b/src/components/player/android/MpvPlayer.tsx index 5b9dd1e9..efa336fa 100644 --- a/src/components/player/android/MpvPlayer.tsx +++ b/src/components/player/android/MpvPlayer.tsx @@ -17,11 +17,13 @@ export interface MpvPlayerProps { 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; } const MpvPlayer = forwardRef((props, ref) => { @@ -80,6 +82,11 @@ const MpvPlayer = forwardRef((props, ref) => { props.onError?.(event?.nativeEvent); }; + const handleTracksChanged = (event: any) => { + console.log('[MpvPlayer] Native onTracksChanged event:', event?.nativeEvent); + props.onTracksChanged?.(event?.nativeEvent); + }; + return ( ((props, ref) => { 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} /> ); }); diff --git a/src/components/player/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx index 9a02f738..0899e2e4 100644 --- a/src/components/player/android/components/VideoSurface.tsx +++ b/src/components/player/android/components/VideoSurface.tsx @@ -51,6 +51,7 @@ interface VideoSurfaceProps { loadStartAtRef?: any; firstFrameAtRef?: any; zoomScale?: number; + onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void; } export const VideoSurface: React.FC = ({ @@ -72,6 +73,7 @@ export const VideoSurface: React.FC = ({ onPinchGestureEvent, onPinchHandlerStateChange, screenDimensions, + onTracksChanged, }) => { // Use the actual stream URL const streamUrl = currentStreamUrl || processedStreamUrl; @@ -122,11 +124,13 @@ export const VideoSurface: React.FC = ({ paused={paused} volume={volume} rate={playbackSpeed} + resizeMode={resizeMode === 'none' ? 'contain' : resizeMode} style={localStyles.player} onLoad={handleLoad} onProgress={handleProgress} onEnd={handleEnd} onError={handleError} + onTracksChanged={onTracksChanged} /> {/* Gesture overlay - transparent, on top of the player */}