diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 5303fe9e2..a73f25851 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -7,6 +7,7 @@ import android.text.SpannableString import android.net.Uri import android.util.Log import android.util.TypedValue +import android.graphics.RectF import android.graphics.Typeface import android.os.Build import android.os.SystemClock @@ -39,6 +40,7 @@ import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.VideoSize import androidx.media3.common.text.Cue import androidx.media3.common.text.CueGroup import androidx.media3.common.util.UnstableApi @@ -254,6 +256,9 @@ private fun ExoPlayerSurface( var selectedExternalSubtitleMimeType by remember(playerSourceKey) { mutableStateOf(null) } val latestSubtitleDelayMs = rememberUpdatedState(subtitleDelayMs) val latestExternalSubtitleMimeType = rememberUpdatedState(selectedExternalSubtitleMimeType) + var playerViewRef by remember { mutableStateOf(null) } + var videoAspectRatio by remember(playerSourceKey) { mutableStateOf(0f) } + val latestVideoAspectRatio = rememberUpdatedState(videoAspectRatio) var decoderPriorityOverride by remember(playerSourceKey) { mutableStateOf(null) } var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf(null) } val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority @@ -342,6 +347,9 @@ private fun ExoPlayerSurface( shouldNormalizeCuePositionProvider = { latestExternalSubtitleMimeType.value == MimeTypes.TEXT_VTT }, + videoBoundsFractionProvider = { + playerViewRef?.videoBoundsFraction(latestVideoAspectRatio.value) + }, ) .setExtensionRendererMode(effectiveDecoderPriority) .setEnableDecoderFallback(true) @@ -449,7 +457,6 @@ private fun ExoPlayerSurface( val pendingSubtitleTrackIndex = remember { mutableListOf() } val pendingAudioTrackSelection = remember { mutableListOf() } - var playerViewRef by remember { mutableStateOf(null) } var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) } var subtitleSelectionJob by remember { mutableStateOf(null) } val isInPip = rememberIsInPictureInPicture() @@ -580,10 +587,6 @@ private fun ExoPlayerSurface( dispatchExoPlayerSnapshot() } - override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) { - latestOnSnapshot.value(exoPlayer.snapshot()) - } - override fun onIsPlayingChanged(isPlaying: Boolean) { Log.i( PLAYER_DIAGNOSTIC_TAG, @@ -608,6 +611,13 @@ private fun ExoPlayerSurface( dispatchExoPlayerSnapshot() } + override fun onVideoSizeChanged(videoSize: VideoSize) { + latestOnSnapshot.value(exoPlayer.snapshot()) + if (videoSize.width > 0 && videoSize.height > 0) { + videoAspectRatio = videoSize.width.toFloat() / videoSize.height.toFloat() + } + } + override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { Log.d(TAG, "onTracksChanged: ${tracks.groups.size} groups total") exoPlayer.logCurrentTracks("onTracksChanged") @@ -1794,11 +1804,50 @@ private fun ExoPlayer.logCurrentTracks(context: String) { Log.d(TAG, "--- end logCurrentTracks ---") } +@androidx.annotation.OptIn(UnstableApi::class) +private fun PlayerView.videoBoundsFraction(aspectRatio: Float): RectF? { + val subtitleView = this.subtitleView ?: return null + val viewWidth = subtitleView.width.toFloat() + val viewHeight = subtitleView.height.toFloat() + if (viewWidth <= 0f || viewHeight <= 0f) return null + + if (aspectRatio > 0f) { + val parentRatio = viewWidth / viewHeight + return if (parentRatio > aspectRatio) { + val fitW = viewHeight * aspectRatio + val leftPx = (viewWidth - fitW) / 2f + RectF(leftPx / viewWidth, 0f, (leftPx + fitW) / viewWidth, 1f) + } else { + val fitH = viewWidth / aspectRatio + val topPx = (viewHeight - fitH) / 2f + RectF(0f, topPx / viewHeight, 1f, (topPx + fitH) / viewHeight) + } + } + + val contentFrame = getTag(androidx.media3.ui.R.id.exo_content_frame) as? AspectRatioFrameLayout + ?: findViewById(androidx.media3.ui.R.id.exo_content_frame) + ?.also { setTag(androidx.media3.ui.R.id.exo_content_frame, it) } + ?: return null + val frameWidth = contentFrame.width.toFloat() + val frameHeight = contentFrame.height.toFloat() + if (frameWidth <= 0f || frameHeight <= 0f) return null + if (frameWidth > viewWidth || frameHeight > viewHeight) return null + val left = contentFrame.x / viewWidth + val top = contentFrame.y / viewHeight + return RectF( + left, + top, + left + frameWidth / viewWidth, + top + frameHeight / viewHeight, + ) +} + @androidx.annotation.OptIn(UnstableApi::class) private class SubtitleOffsetRenderersFactory( context: Context, private val subtitleDelayUsProvider: () -> Long, private val shouldNormalizeCuePositionProvider: () -> Boolean, + private val videoBoundsFractionProvider: () -> RectF?, ) : DefaultRenderersFactory(context) { override fun buildTextRenderers( context: Context, @@ -1810,6 +1859,7 @@ private class SubtitleOffsetRenderersFactory( val normalizingOutput = CueNormalizingTextOutput( delegate = output, shouldNormalizeCuePositionProvider = shouldNormalizeCuePositionProvider, + videoBoundsFractionProvider = videoBoundsFractionProvider, ) val startIndex = out.size super.buildTextRenderers(context, normalizingOutput, outputLooper, extensionRendererMode, out) @@ -1825,6 +1875,7 @@ private class SubtitleOffsetRenderersFactory( private class CueNormalizingTextOutput( private val delegate: TextOutput, private val shouldNormalizeCuePositionProvider: () -> Boolean, + private val videoBoundsFractionProvider: () -> RectF?, ) : TextOutput { override fun onCues(cueGroup: CueGroup) { val processed = cueGroup.cues.map(::processCue) @@ -1841,9 +1892,36 @@ private class CueNormalizingTextOutput( if (shouldNormalizeCuePositionProvider()) { processed = normalizeCuePosition(processed) } + if (processed.bitmap != null) { + val bounds = videoBoundsFractionProvider() + if (bounds != null && bounds.width() > 0f && bounds.height() > 0f) { + val isIdentity = bounds.left == 0f && bounds.top == 0f + && bounds.width() == 1f && bounds.height() == 1f + if (!isIdentity) { + processed = remapBitmapCueToVideoBounds(processed, bounds) + } + } + } return processed } + private fun remapBitmapCueToVideoBounds(cue: Cue, bounds: RectF): Cue { + val builder = cue.buildUpon() + if (cue.position != Cue.DIMEN_UNSET) { + builder.setPosition(bounds.left + cue.position * bounds.width()) + } + if (cue.size != Cue.DIMEN_UNSET) { + builder.setSize(cue.size * bounds.width()) + } + if (cue.lineType == Cue.LINE_TYPE_FRACTION && cue.line != Cue.DIMEN_UNSET) { + builder.setLine(bounds.top + cue.line * bounds.height(), Cue.LINE_TYPE_FRACTION) + } + if (cue.bitmapHeight != Cue.DIMEN_UNSET) { + builder.setBitmapHeight(cue.bitmapHeight * bounds.height()) + } + return builder.build() + } + private fun normalizeCuePosition(cue: Cue): Cue { if (cue.bitmap != null || cue.verticalType != Cue.TYPE_UNSET || cue.line == Cue.DIMEN_UNSET) { return cue