mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-09 08:27:32 +00:00
Improve player visuals and trailer loading
This commit is contained in:
parent
1bc683acbf
commit
d61a97000a
8 changed files with 168 additions and 109 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ internal fun MobileMovieCard(
|
|||
)
|
||||
}
|
||||
|
||||
private fun Meta.toCatalogCardUiModel(
|
||||
internal fun Meta.toCatalogCardUiModel(
|
||||
cardLayout: String,
|
||||
artworkPreference: String?,
|
||||
profile: UserProfile?,
|
||||
|
|
|
|||
Loading…
Reference in a new issue