fixed subtitle rendering, added aspect ratio support

This commit is contained in:
tapframe 2025-12-23 14:25:52 +05:30
parent 9504d48607
commit 3cea291901
5 changed files with 256 additions and 7 deletions

View file

@ -28,6 +28,7 @@ class MPVView @JvmOverloads constructor(
var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null
var onEndCallback: (() -> Unit)? = null var onEndCallback: (() -> Unit)? = null
var onErrorCallback: ((message: String) -> Unit)? = null var onErrorCallback: ((message: String) -> Unit)? = null
var onTracksChangedCallback: ((audioTracks: List<Map<String, Any>>, subtitleTracks: List<Map<String, Any>>) -> Unit)? = null
init { init {
surfaceTextureListener = this surfaceTextureListener = this
@ -89,8 +90,13 @@ class MPVView @JvmOverloads constructor(
MPVLib.setOptionString("vo", "gpu") MPVLib.setOptionString("vo", "gpu")
MPVLib.setOptionString("gpu-context", "android") MPVLib.setOptionString("gpu-context", "android")
MPVLib.setOptionString("opengl-es", "yes") 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") MPVLib.setOptionString("ao", "audiotrack,opensles")
// Network caching for streaming // Network caching for streaming
@ -99,6 +105,43 @@ class MPVView @JvmOverloads constructor(
MPVLib.setOptionString("cache", "yes") MPVLib.setOptionString("cache", "yes")
MPVLib.setOptionString("cache-secs", "30") 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 // Disable terminal/input
MPVLib.setOptionString("terminal", "no") MPVLib.setOptionString("terminal", "no")
MPVLib.setOptionString("input-default-bindings", "no") MPVLib.setOptionString("input-default-bindings", "no")
@ -120,6 +163,11 @@ class MPVView @JvmOverloads constructor(
MPVLib.observeProperty("width", MPV_FORMAT_INT64) MPVLib.observeProperty("width", MPV_FORMAT_INT64)
MPVLib.observeProperty("height", MPV_FORMAT_INT64) MPVLib.observeProperty("height", MPV_FORMAT_INT64)
MPVLib.observeProperty("track-list", MPV_FORMAT_NONE) 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) { private fun loadFile(url: String) {
@ -176,11 +224,52 @@ class MPVView @JvmOverloads constructor(
} }
fun setSubtitleTrack(trackId: Int) { fun setSubtitleTrack(trackId: Int) {
Log.d(TAG, "setSubtitleTrack called: trackId=$trackId, isMpvInitialized=$isMpvInitialized")
if (isMpvInitialized) { if (isMpvInitialized) {
if (trackId == -1) { if (trackId == -1) {
Log.d(TAG, "Disabling subtitles (sid=no)")
MPVLib.setPropertyString("sid", "no") MPVLib.setPropertyString("sid", "no")
MPVLib.setPropertyString("sub-visibility", "no")
} else { } else {
Log.d(TAG, "Setting subtitle track to: $trackId")
MPVLib.setPropertyInt("sid", 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,11 +280,59 @@ class MPVView @JvmOverloads constructor(
Log.d(TAG, "Property changed: $property") Log.d(TAG, "Property changed: $property")
when (property) { when (property) {
"track-list" -> { "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<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) { override fun eventProperty(property: String, value: Long) {
Log.d(TAG, "Property $property = $value (Long)") Log.d(TAG, "Property $property = $value (Long)")
} }
@ -244,7 +381,22 @@ class MPVView @JvmOverloads constructor(
} }
} }
MPV_EVENT_END_FILE -> { 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()
}
} }
} }
} }

View file

@ -58,6 +58,35 @@ class MpvPlayerViewManager(
sendEvent(context, view.id, "onError", event) 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 return view
} }
@ -72,6 +101,7 @@ class MpvPlayerViewManager(
.put("onProgress", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onProgress"))) .put("onProgress", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onProgress")))
.put("onEnd", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onEnd"))) .put("onEnd", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onEnd")))
.put("onError", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onError"))) .put("onError", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onError")))
.put("onTracksChanged", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onTracksChanged")))
.build() .build()
} }
@ -128,4 +158,9 @@ class MpvPlayerViewManager(
// Intentionally ignoring - background color would block the TextureView content // Intentionally ignoring - background color would block the TextureView content
// Leave the view transparent // Leave the view transparent
} }
@ReactProp(name = "resizeMode")
fun setResizeMode(view: MPVView, resizeMode: String?) {
view.setResizeMode(resizeMode ?: "contain")
}
} }

View file

@ -396,11 +396,45 @@ const AndroidVideoPlayer: React.FC = () => {
return; 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); modals.setShowErrorModal(true);
}} }}
onBuffer={(buf) => playerState.setIsBuffering(buf.isBuffering)} onBuffer={(buf) => playerState.setIsBuffering(buf.isBuffering)}
onTracksUpdate={vlcHook.handleVlcTracksUpdate} 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} vlcPlayerRef={vlcHook.vlcPlayerRef}
mpvPlayerRef={mpvPlayerRef} mpvPlayerRef={mpvPlayerRef}
videoRef={videoRef} videoRef={videoRef}
@ -504,8 +538,15 @@ const AndroidVideoPlayer: React.FC = () => {
ksAudioTracks={tracksHook.ksAudioTracks} ksAudioTracks={tracksHook.ksAudioTracks}
selectedAudioTrack={tracksHook.computedSelectedAudioTrack} selectedAudioTrack={tracksHook.computedSelectedAudioTrack}
selectAudioTrack={(trackId) => { selectAudioTrack={(trackId) => {
useVLC ? vlcHook.selectVlcAudioTrack(trackId) : if (useVLC) {
vlcHook.selectVlcAudioTrack(trackId);
} else {
tracksHook.setSelectedAudioTrack(trackId === null ? null : { type: 'index', value: trackId }); 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 fetchAvailableSubtitles={() => { }} // Placeholder
loadWyzieSubtitle={() => { }} // Placeholder loadWyzieSubtitle={() => { }} // Placeholder
selectTextTrack={(trackId) => { 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); modals.setShowSubtitleModal(false);
}} }}
disableCustomSubtitles={() => { }} // Placeholder disableCustomSubtitles={() => { }} // Placeholder

View file

@ -17,11 +17,13 @@ export interface MpvPlayerProps {
paused?: boolean; paused?: boolean;
volume?: number; volume?: number;
rate?: number; rate?: number;
resizeMode?: 'contain' | 'cover' | 'stretch';
style?: any; style?: any;
onLoad?: (data: { duration: number; width: number; height: number }) => void; onLoad?: (data: { duration: number; width: number; height: number }) => void;
onProgress?: (data: { currentTime: number; duration: number }) => void; onProgress?: (data: { currentTime: number; duration: number }) => void;
onEnd?: () => void; onEnd?: () => void;
onError?: (error: { error: string }) => void; onError?: (error: { error: string }) => void;
onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void;
} }
const MpvPlayer = forwardRef<MpvPlayerRef, MpvPlayerProps>((props, ref) => { const MpvPlayer = forwardRef<MpvPlayerRef, MpvPlayerProps>((props, ref) => {
@ -80,6 +82,11 @@ const MpvPlayer = forwardRef<MpvPlayerRef, MpvPlayerProps>((props, ref) => {
props.onError?.(event?.nativeEvent); props.onError?.(event?.nativeEvent);
}; };
const handleTracksChanged = (event: any) => {
console.log('[MpvPlayer] Native onTracksChanged event:', event?.nativeEvent);
props.onTracksChanged?.(event?.nativeEvent);
};
return ( return (
<MpvPlayerNative <MpvPlayerNative
ref={nativeRef} ref={nativeRef}
@ -88,10 +95,12 @@ const MpvPlayer = forwardRef<MpvPlayerRef, MpvPlayerProps>((props, ref) => {
paused={props.paused ?? true} paused={props.paused ?? true}
volume={props.volume ?? 1.0} volume={props.volume ?? 1.0}
rate={props.rate ?? 1.0} rate={props.rate ?? 1.0}
resizeMode={props.resizeMode ?? 'contain'}
onLoad={handleLoad} onLoad={handleLoad}
onProgress={handleProgress} onProgress={handleProgress}
onEnd={handleEnd} onEnd={handleEnd}
onError={handleError} onError={handleError}
onTracksChanged={handleTracksChanged}
/> />
); );
}); });

View file

@ -51,6 +51,7 @@ interface VideoSurfaceProps {
loadStartAtRef?: any; loadStartAtRef?: any;
firstFrameAtRef?: any; firstFrameAtRef?: any;
zoomScale?: number; zoomScale?: number;
onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void;
} }
export const VideoSurface: React.FC<VideoSurfaceProps> = ({ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
@ -72,6 +73,7 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
onPinchGestureEvent, onPinchGestureEvent,
onPinchHandlerStateChange, onPinchHandlerStateChange,
screenDimensions, screenDimensions,
onTracksChanged,
}) => { }) => {
// Use the actual stream URL // Use the actual stream URL
const streamUrl = currentStreamUrl || processedStreamUrl; const streamUrl = currentStreamUrl || processedStreamUrl;
@ -122,11 +124,13 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
paused={paused} paused={paused}
volume={volume} volume={volume}
rate={playbackSpeed} rate={playbackSpeed}
resizeMode={resizeMode === 'none' ? 'contain' : resizeMode}
style={localStyles.player} style={localStyles.player}
onLoad={handleLoad} onLoad={handleLoad}
onProgress={handleProgress} onProgress={handleProgress}
onEnd={handleEnd} onEnd={handleEnd}
onError={handleError} onError={handleError}
onTracksChanged={onTracksChanged}
/> />
{/* Gesture overlay - transparent, on top of the player */} {/* Gesture overlay - transparent, on top of the player */}