From d61a97000aea8cb303df07bb9a3c1cf29f108b50 Mon Sep 17 00:00:00 2001 From: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:58:39 +0300 Subject: [PATCH] Improve player visuals and trailer loading --- .../app/ui/catalog/HomeBillboardRuntime.kt | 8 +- .../ui/catalog/NativeLibassSubtitleOverlay.kt | 51 ++++++--- .../app/ui/catalog/PlayerPlaybackSurface.kt | 94 ++++------------ .../com/fluxa/app/ui/catalog/PlayerScreen.kt | 2 +- .../app/ui/catalog/PlayerScreenContent.kt | 2 +- .../fluxa/app/ui/catalog/TrailerResolver.kt | 100 +++++++++++++++--- .../catalog/mobile/detail/MobileDetailHero.kt | 18 +++- .../ui/catalog/mobile/home/MobileMovieCard.kt | 2 +- 8 files changed, 168 insertions(+), 109 deletions(-) diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeBillboardRuntime.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeBillboardRuntime.kt index 5382cc9..eb27064 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeBillboardRuntime.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeBillboardRuntime.kt @@ -285,7 +285,9 @@ internal class HomeBillboardRuntime( } internal suspend fun resolvePlayableTrailerUrl(trailers: List): 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 } diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/NativeLibassSubtitleOverlay.kt b/app/src/main/java/com/fluxa/app/ui/catalog/NativeLibassSubtitleOverlay.kt index 1f38d22..39bb3d8 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/NativeLibassSubtitleOverlay.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/NativeLibassSubtitleOverlay.kt @@ -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 { diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerPlaybackSurface.kt b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerPlaybackSurface.kt index 89fa843..7b9fb00 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerPlaybackSurface.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerPlaybackSurface.kt @@ -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(null) } + val nativeLibassSurfaceView = remember { mutableStateOf(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(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(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) diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreen.kt b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreen.kt index 13bc2fc..b9d472a 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreen.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreen.kt @@ -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()) { diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreenContent.kt b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreenContent.kt index 0bda184..35038cf 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreenContent.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreenContent.kt @@ -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 { diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/TrailerResolver.kt b/app/src/main/java/com/fluxa/app/ui/catalog/TrailerResolver.kt index 3d66555..891a8d0 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/TrailerResolver.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/TrailerResolver.kt @@ -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 + val audioUrl: String?, + val subtitles: List, + val streamMimeType: String? ) internal sealed interface TrailerResolveResult { @@ -33,7 +42,13 @@ internal sealed interface TrailerResolveResult { internal object TrailerResolver { private val memCache = ConcurrentHashMap(16) - private val httpClient = OkHttpClient() + private val streamMimeTypes = ConcurrentHashMap(16) + private val httpClient = OkHttpClient.Builder() + .dns { hostname -> + val addresses = Dns.SYSTEM.lookup(hostname) + addresses.filterIsInstance().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? { + 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 { 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() +} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/mobile/detail/MobileDetailHero.kt b/app/src/main/java/com/fluxa/app/ui/catalog/mobile/detail/MobileDetailHero.kt index 304fea3..07070c1 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/mobile/detail/MobileDetailHero.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/mobile/detail/MobileDetailHero.kt @@ -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 diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/mobile/home/MobileMovieCard.kt b/app/src/main/java/com/fluxa/app/ui/catalog/mobile/home/MobileMovieCard.kt index a423f13..351f478 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/mobile/home/MobileMovieCard.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/mobile/home/MobileMovieCard.kt @@ -47,7 +47,7 @@ internal fun MobileMovieCard( ) } -private fun Meta.toCatalogCardUiModel( +internal fun Meta.toCatalogCardUiModel( cardLayout: String, artworkPreference: String?, profile: UserProfile?,