Improve player visuals and trailer loading

This commit is contained in:
KhooLy 2026-07-14 16:58:39 +03:00
parent 1bc683acbf
commit d61a97000a
8 changed files with 168 additions and 109 deletions

View file

@ -285,7 +285,9 @@ internal class HomeBillboardRuntime(
}
internal suspend fun resolvePlayableTrailerUrl(trailers: List<DetailTrailer>): String? {
val trailerUrl = trailers.firstOrNull { it.url.isNotBlank() }?.url ?: return null
if (trailerUrl.isDirectVideoPreviewUrl()) return trailerUrl
return (TrailerResolver.resolve(trailerUrl) as? TrailerResolveResult.Ok)?.data?.streamUrl
val youtubeUrl = trailers.firstOrNull { it.url.extractYoutubeVideoId() != null }?.url
if (youtubeUrl != null) {
return (TrailerResolver.resolve(youtubeUrl) as? TrailerResolveResult.Ok)?.data?.streamUrl
}
return trailers.firstOrNull { it.url.isDirectVideoPreviewUrl() }?.url
}

View file

@ -12,12 +12,12 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.video.VideoFrameMetadataListener
import com.fluxa.app.player.ExternalSubtitleTrack
import com.fluxa.app.player.LibassDebugLog
import com.fluxa.app.player.LibassRenderThread
import com.fluxa.app.player.LibassVideoFrame
import com.fluxa.app.player.MediaPlayerController
import com.fluxa.app.player.MediaTrack
import com.fluxa.app.player.MkvNativeAssExtractor
@ -60,7 +60,7 @@ internal fun NativeLibassSubtitleOverlay(
externalSubtitle: ExternalSubtitleTrack?,
embeddedSubtitle: NativeAssTrack?,
subtitleDelayMs: Long,
modifier: Modifier = Modifier
surfaceView: NativeLibassSubtitleSurfaceView?
) {
val context = androidx.compose.ui.platform.LocalContext.current
val renderThread = remember(exoPlayer) { MediaPlayerController.getLibassRelay(exoPlayer)?.renderThread }
@ -110,29 +110,32 @@ internal fun NativeLibassSubtitleOverlay(
onDispose { renderThread?.setLocalRenderer(null) }
}
AndroidView(
factory = { NativeLibassSubtitleSurfaceView(it) },
update = { view ->
view.exoPlayer = exoPlayer
view.renderThread = renderThread
view.subtitleDelayMs = subtitleDelayMs
},
modifier = modifier
)
surfaceView?.configure(exoPlayer, renderThread, subtitleDelayMs)
}
private class NativeLibassSubtitleSurfaceView(context: Context) : SurfaceView(context), SurfaceHolder.Callback {
internal class NativeLibassSubtitleSurfaceView(context: Context) : SurfaceView(context), SurfaceHolder.Callback {
var exoPlayer: ExoPlayer? = null
set(value) {
field?.clearVideoFrameMetadataListener(ptsListener)
field?.let {
it.clearVideoFrameMetadataListener(ptsListener)
it.removeListener(playerListener)
}
field = value
if (isAttachedToWindow) value?.setVideoFrameMetadataListener(ptsListener)
if (isAttachedToWindow) {
value?.setVideoFrameMetadataListener(ptsListener)
value?.addListener(playerListener)
}
value?.videoSize?.let(playerListener::onVideoSizeChanged)
}
var renderThread: LibassRenderThread? = null
set(value) {
field = value
value?.setDelay(subtitleDelayMs)
attachedSurface?.let { surface ->
value?.setSurface(surface, surfaceWidth, surfaceHeight)
value?.setVideoFrame(LibassVideoFrame(surfaceWidth, surfaceHeight, 0, 0))
}
}
var subtitleDelayMs: Long = 0L
@ -142,6 +145,9 @@ private class NativeLibassSubtitleSurfaceView(context: Context) : SurfaceView(co
}
private var lastSurfaceLogMs = 0L
private var attachedSurface: android.view.Surface? = null
private var surfaceWidth = 0
private var surfaceHeight = 0
private val ptsListener = VideoFrameMetadataListener { presentationTimeUs, _, _, _ ->
renderThread?.onVideoFrame(presentationTimeUs / 1000L)
@ -157,12 +163,14 @@ private class NativeLibassSubtitleSurfaceView(context: Context) : SurfaceView(co
super.onAttachedToWindow()
LibassDebugLog.d("overlay view attached size=${width}x$height")
exoPlayer?.setVideoFrameMetadataListener(ptsListener)
exoPlayer?.addListener(playerListener)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
LibassDebugLog.d("overlay view detached")
exoPlayer?.clearVideoFrameMetadataListener(ptsListener)
exoPlayer?.removeListener(playerListener)
}
override fun surfaceCreated(holder: SurfaceHolder) {}
@ -173,13 +181,28 @@ private class NativeLibassSubtitleSurfaceView(context: Context) : SurfaceView(co
LibassDebugLog.d("overlay surface changed ${width}x$height")
lastSurfaceLogMs = now
}
attachedSurface = holder.surface
surfaceWidth = width
surfaceHeight = height
renderThread?.setSurface(holder.surface, width, height)
renderThread?.setVideoFrame(LibassVideoFrame(width, height, 0, 0))
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
LibassDebugLog.d("overlay surface destroyed")
attachedSurface = null
surfaceWidth = 0
surfaceHeight = 0
renderThread?.setSurface(null, 0, 0)
}
private val playerListener = object : androidx.media3.common.Player.Listener {}
fun configure(player: ExoPlayer, renderThread: LibassRenderThread?, subtitleDelayMs: Long) {
exoPlayer = player
this.renderThread = renderThread
this.subtitleDelayMs = subtitleDelayMs
}
}
private val subtitleHttpClient: OkHttpClient by lazy {

View file

@ -2,13 +2,9 @@
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class, androidx.compose.animation.ExperimentalAnimationApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.fluxa.app.ui.catalog
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import com.fluxa.app.common.AppStrings
import android.view.LayoutInflater
import android.view.SurfaceView
import android.view.animation.DecelerateInterpolator
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@ -50,11 +46,11 @@ import com.fluxa.app.player.MpvAndroidSurfaceView
import com.fluxa.app.player.MpvEmbeddedPlayer
import com.fluxa.app.player.PlayerEngine
import com.fluxa.app.player.TorrentStreamStatus
import kotlin.math.roundToInt
private data class ExoSurfaceConfig(
val resizeMode: Int,
val zoomScale: Float,
val fillScale: Float,
val subtitleSize: Float,
val subtitleTextOpacity: Float,
val subtitleBackgroundOpacity: Float,
@ -67,11 +63,6 @@ private data class MpvSurfaceConfig(
val zoomScale: Float
)
private data class SurfaceCropTarget(
val width: Int,
val height: Int
)
@Composable
internal fun BoxScope.PlayerPlaybackSurface(
content: PlayerContentUiModel,
@ -148,6 +139,7 @@ internal fun BoxScope.PlayerPlaybackSurface(
onParentsGuideAnimationComplete: () -> Unit = {}
) {
val seekSurfaceViewRef = remember { mutableStateOf<SurfaceView?>(null) }
val nativeLibassSurfaceView = remember { mutableStateOf<NativeLibassSubtitleSurfaceView?>(null) }
if (!resolvedUrl.isNullOrEmpty()) {
if (useMpvBackend) {
@ -191,7 +183,7 @@ internal fun BoxScope.PlayerPlaybackSurface(
}
val exoSurfaceConfig = remember(
resizeMode, videoZoomScale,
resizeMode, videoZoomScale, fillScale,
activeProfile?.safeSubtitleSize,
activeProfile?.safeSubtitleTextOpacity,
activeProfile?.safeSubtitleBackgroundOpacity,
@ -201,6 +193,7 @@ internal fun BoxScope.PlayerPlaybackSurface(
ExoSurfaceConfig(
resizeMode = resizeMode,
zoomScale = videoZoomScale,
fillScale = fillScale,
subtitleSize = activeProfile?.safeSubtitleSize ?: 20f,
subtitleTextOpacity = activeProfile?.safeSubtitleTextOpacity ?: 1f,
subtitleBackgroundOpacity = activeProfile?.safeSubtitleBackgroundOpacity ?: 0.75f,
@ -220,6 +213,16 @@ internal fun BoxScope.PlayerPlaybackSurface(
android.view.Gravity.CENTER
)
applyExoSurfaceConfig(exoSurfaceConfig, activeProfile)
val contentFrame = findViewById<android.widget.FrameLayout>(androidx.media3.ui.R.id.exo_content_frame)
nativeLibassSurfaceView.value = NativeLibassSubtitleSurfaceView(ctx).also { overlay ->
contentFrame.addView(
overlay,
android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
android.widget.FrameLayout.LayoutParams.MATCH_PARENT
)
)
}
}
},
modifier = Modifier.fillMaxSize(),
@ -235,7 +238,7 @@ internal fun BoxScope.PlayerPlaybackSurface(
externalSubtitle = selectedNativeAssSubtitle(currentSubtitle, currentExternalSubtitles),
embeddedSubtitle = selectedEmbeddedNativeAssTrack(currentSubtitle, embeddedNativeAssTracks),
subtitleDelayMs = subtitleDelayMs,
modifier = Modifier.fillMaxSize()
surfaceView = nativeLibassSurfaceView.value
)
}
}
@ -383,10 +386,12 @@ private fun PlayerView.applyExoSurfaceConfig(config: ExoSurfaceConfig, profile:
resizeMode = config.resizeMode
scaleX = 1.0f
scaleY = 1.0f
videoSurfaceView?.let { surface ->
surface.scaleX = 1.0f
surface.scaleY = 1.0f
applySurfaceCropLayout(surface, config)
val contentScale = if (config.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_ZOOM) {
(config.zoomScale / config.fillScale.coerceAtLeast(1.0f)).coerceAtLeast(1.0f)
} else 1.0f
findViewById<android.view.View>(androidx.media3.ui.R.id.exo_content_frame)?.apply {
scaleX = contentScale
scaleY = contentScale
}
subtitleView?.let { sv ->
sv.setApplyEmbeddedStyles(true)
@ -398,63 +403,6 @@ private fun PlayerView.applyExoSurfaceConfig(config: ExoSurfaceConfig, profile:
setTag(R.id.player_surface_config_tag, config)
}
private fun applySurfaceCropLayout(surface: android.view.View, config: ExoSurfaceConfig) {
val parent = surface.parent as? android.view.View ?: return
val scale = if (config.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_ZOOM) {
config.zoomScale.coerceAtLeast(1.0f)
} else {
1.0f
}
val baseWidth = parent.width.takeIf { it > 0 } ?: surface.width
val baseHeight = parent.height.takeIf { it > 0 } ?: surface.height
if (baseWidth <= 0 || baseHeight <= 0) return
val targetWidth = (baseWidth * scale).roundToInt().coerceAtLeast(baseWidth)
val targetHeight = (baseHeight * scale).roundToInt().coerceAtLeast(baseHeight)
val target = SurfaceCropTarget(targetWidth, targetHeight)
if (surface.getTag(R.id.player_surface_crop_target_tag) == target) return
surface.setTag(R.id.player_surface_crop_target_tag, target)
(surface.getTag(R.id.player_surface_crop_animator_tag) as? ValueAnimator)?.cancel()
val current = surface.layoutParams as? android.widget.FrameLayout.LayoutParams
val startWidth = current?.width?.takeIf { it > 0 } ?: surface.width.takeIf { it > 0 } ?: baseWidth
val startHeight = current?.height?.takeIf { it > 0 } ?: surface.height.takeIf { it > 0 } ?: baseHeight
if (startWidth == targetWidth && startHeight == targetHeight) {
surface.layoutParams = android.widget.FrameLayout.LayoutParams(
targetWidth,
targetHeight,
android.view.Gravity.CENTER
)
return
}
ValueAnimator.ofFloat(0f, 1f).apply {
duration = 140L
interpolator = DecelerateInterpolator()
addUpdateListener { animator ->
val fraction = animator.animatedFraction
val animatedWidth = (startWidth + ((targetWidth - startWidth) * fraction)).roundToInt()
val animatedHeight = (startHeight + ((targetHeight - startHeight) * fraction)).roundToInt()
surface.layoutParams = android.widget.FrameLayout.LayoutParams(
animatedWidth,
animatedHeight,
android.view.Gravity.CENTER
)
}
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
surface.layoutParams = android.widget.FrameLayout.LayoutParams(
targetWidth,
targetHeight,
android.view.Gravity.CENTER
)
if (surface.getTag(R.id.player_surface_crop_animator_tag) == this@apply) {
surface.setTag(R.id.player_surface_crop_animator_tag, null)
}
}
})
surface.setTag(R.id.player_surface_crop_animator_tag, this)
start()
}
}
private fun MpvAndroidSurfaceView.applyMpvSurfaceConfig(config: MpvSurfaceConfig) {
if (getTag(R.id.player_surface_config_tag) == config) return
bind(config.player)

View file

@ -38,7 +38,7 @@ private fun castReachableUrl(rawUrl: String): String {
return rawUrl
}
val lanIp = deviceLanIpv4() ?: return rawUrl
val token = TorrServerEngine.castAccessToken
val token = TorrentServerEngine.castAccessToken
val builder = uri.buildUpon()
.authority(if (uri.port > 0) "$lanIp:${uri.port}" else lanIp)
if (token.isNotBlank() && uri.getQueryParameter("access_token").isNullOrBlank()) {

View file

@ -182,7 +182,7 @@ internal fun PlayerScreenContent(
}
} else if (zoomDelta < 1.0f && state.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_ZOOM) {
val nextScale = state.videoZoomScale * zoomDelta
if (nextScale < fillScale) {
if (nextScale <= fillScale * 1.05f) {
state.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
state.videoZoomScale = 1.0f
} else {

View file

@ -1,6 +1,11 @@
package com.fluxa.app.ui.catalog
import android.util.Log
import androidx.media3.common.MimeTypes
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
import androidx.media3.datasource.okhttp.OkHttpDataSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
@ -8,7 +13,9 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Dns
import org.json.JSONObject
import java.net.Inet4Address
import java.util.concurrent.ConcurrentHashMap
internal data class SubtitleInfo(
@ -21,7 +28,9 @@ internal data class SubtitleInfo(
internal data class TrailerResult(
val streamUrl: String,
val subtitles: List<SubtitleInfo>
val audioUrl: String?,
val subtitles: List<SubtitleInfo>,
val streamMimeType: String?
)
internal sealed interface TrailerResolveResult {
@ -33,7 +42,13 @@ internal sealed interface TrailerResolveResult {
internal object TrailerResolver {
private val memCache = ConcurrentHashMap<String, TrailerResolveResult>(16)
private val httpClient = OkHttpClient()
private val streamMimeTypes = ConcurrentHashMap<String, String>(16)
private val httpClient = OkHttpClient.Builder()
.dns { hostname ->
val addresses = Dns.SYSTEM.lookup(hostname)
addresses.filterIsInstance<Inet4Address>().ifEmpty { addresses }
}
.build()
fun init(cacheDir: java.io.File) = Unit
@ -50,10 +65,22 @@ internal object TrailerResolver {
Log.e("TrailerResolver", "resolve($id) → ${e.javaClass.simpleName}: ${e.message}")
TrailerResolveResult.Failed
}
if (result is TrailerResolveResult.Ok) memCache[id] = result
if (result is TrailerResolveResult.Ok) {
memCache[id] = result
result.data.streamMimeType?.let { streamMimeTypes[result.data.streamUrl] = it }
}
result
}
fun streamMimeType(url: String): String? =
streamMimeTypes[url] ?: if (url.contains(".m3u8", ignoreCase = true)) MimeTypes.APPLICATION_M3U8 else null
fun mediaDataSourceFactory(): DataSource.Factory {
val upstream = OkHttpDataSource.Factory(httpClient)
.setUserAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
return DataSource.Factory { TrailerRangeDataSource(upstream.createDataSource()) }
}
private fun resolveInnertubeTrailer(videoId: String): TrailerResolveResult {
val body = JSONObject()
.put("videoId", videoId)
@ -80,20 +107,17 @@ internal object TrailerResolver {
}
if (playability?.optString("status") != "OK") return TrailerResolveResult.Failed
val streaming = payload.optJSONObject("streamingData") ?: return TrailerResolveResult.Failed
val streamUrl = streaming.optString("hlsManifestUrl").takeIf { it.isNotBlank() }
val hlsUrl = streaming.optString("hlsManifestUrl").takeIf { it.isNotBlank() }
?.let(::bestHlsVariant)
val adaptivePair = streaming.optJSONArray("adaptiveFormats")?.let(::bestAdaptivePair)
val streamUrl = hlsUrl
?: adaptivePair?.first
?: streaming.optJSONArray("formats")?.let { formats ->
(0 until formats.length())
.asSequence()
.map { formats.optJSONObject(it)?.optString("url").orEmpty() }
.firstOrNull { it.isNotBlank() }
}
?: streaming.optJSONArray("adaptiveFormats")?.let { formats ->
(0 until formats.length())
.asSequence()
.map { formats.optJSONObject(it)?.optString("url").orEmpty() }
.firstOrNull { it.isNotBlank() }
}
?: return TrailerResolveResult.Failed
val subtitles = payload.optJSONObject("captions")
?.optJSONObject("playerCaptionsTracklistRenderer")
@ -112,10 +136,34 @@ internal object TrailerResolver {
}
}
}.orEmpty()
return TrailerResolveResult.Ok(TrailerResult(streamUrl, subtitles))
return TrailerResolveResult.Ok(
TrailerResult(
streamUrl = streamUrl,
audioUrl = if (hlsUrl == null) adaptivePair?.second else null,
subtitles = subtitles,
streamMimeType = if (hlsUrl != null) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP4
)
)
}
}
private fun bestAdaptivePair(formats: org.json.JSONArray): Pair<String, String>? {
val entries = (0 until formats.length()).mapNotNull(formats::optJSONObject)
val video = entries
.asSequence()
.filter { it.optString("url").isNotBlank() }
.filter { it.optString("mimeType").startsWith("video/mp4; codecs=\"avc1") }
.maxWithOrNull(compareBy<JSONObject> { it.optInt("height") }.thenBy { it.optInt("bitrate") })
?: return null
val audio = entries
.asSequence()
.filter { it.optString("url").isNotBlank() }
.filter { it.optString("mimeType").startsWith("audio/mp4") }
.maxByOrNull { it.optInt("bitrate") }
?: return null
return video.optString("url") to audio.optString("url")
}
private fun bestHlsVariant(masterUrl: String): String {
val request = Request.Builder()
.url(masterUrl)
@ -151,6 +199,7 @@ internal object TrailerResolver {
"ok" -> TrailerResolveResult.Ok(
TrailerResult(
streamUrl = response.getString("streamUrl"),
audioUrl = response.optString("audioUrl").takeIf { it.isNotBlank() },
subtitles = response.optJSONArray("subtitles")?.let { tracks ->
(0 until tracks.length()).map { i ->
val track = tracks.getJSONObject(i)
@ -162,10 +211,37 @@ internal object TrailerResolver {
isAuto = track.getBoolean("isAuto")
)
}
} ?: emptyList()
} ?: emptyList(),
streamMimeType = response.optString("streamMimeType").takeIf { it.isNotBlank() }
)
)
"geo_blocked" -> TrailerResolveResult.GeoBlocked
else -> TrailerResolveResult.Failed
}
}
private class TrailerRangeDataSource(
private val delegate: DataSource
) : DataSource {
override fun addTransferListener(transferListener: TransferListener) {
delegate.addTransferListener(transferListener)
}
override fun open(dataSpec: DataSpec): Long {
val headers = dataSpec.httpRequestHeaders
val request = if (headers.keys.any { it.equals("Range", ignoreCase = true) }) {
dataSpec
} else {
dataSpec.withRequestHeaders(headers + ("Range" to "bytes=0-"))
}
return delegate.open(request)
}
override fun read(buffer: ByteArray, offset: Int, length: Int): Int = delegate.read(buffer, offset, length)
override fun getUri() = delegate.uri
override fun getResponseHeaders() = delegate.responseHeaders
override fun close() = delegate.close()
}

View file

@ -41,7 +41,10 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import coil3.compose.AsyncImage
@ -127,10 +130,17 @@ internal fun MobileDetailHero(
.setMaxVideoSize(1920, 1080)
.setForceHighestSupportedBitrate(true)
.build()
player.setMediaItem(
MediaItem.Builder().setUri(r.data.streamUrl)
.setSubtitleConfigurations(subs).build()
)
val videoItem = MediaItem.Builder().setUri(r.data.streamUrl)
.setSubtitleConfigurations(subs).build()
val audioUrl = r.data.audioUrl
if (audioUrl == null) {
player.setMediaItem(videoItem)
} else {
val mediaSourceFactory = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))
val videoSource = mediaSourceFactory.createMediaSource(videoItem)
val audioSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(audioUrl))
player.setMediaSource(MergingMediaSource(videoSource, audioSource))
}
player.prepare()
player.playWhenReady = true
isPlaying = true

View file

@ -47,7 +47,7 @@ internal fun MobileMovieCard(
)
}
private fun Meta.toCatalogCardUiModel(
internal fun Meta.toCatalogCardUiModel(
cardLayout: String,
artworkPreference: String?,
profile: UserProfile?,