Prefer classic Xtream TS URLs for live IPTV playback.
Match Smarters by serving /live/user/pass/id.ts first from master-api, and tune the Android live player buffer for steadier TS playback. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5524b3269d
commit
fe4b1210ac
8 changed files with 318 additions and 50 deletions
|
|
@ -12,8 +12,8 @@ android {
|
||||||
applicationId = "nl.vonas.mediacluster.tv"
|
applicationId = "nl.vonas.mediacluster.tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 22
|
versionCode = 33
|
||||||
versionName = "0.9.0"
|
versionName = "0.11.1"
|
||||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
|
@ -59,6 +59,7 @@ dependencies {
|
||||||
// Media3 + FFmpeg-audio (DTS/TrueHD/E-AC3 → PCM) via nextlib
|
// Media3 + FFmpeg-audio (DTS/TrueHD/E-AC3 → PCM) via nextlib
|
||||||
val media3 = "1.10.1"
|
val media3 = "1.10.1"
|
||||||
implementation("androidx.media3:media3-exoplayer:$media3")
|
implementation("androidx.media3:media3-exoplayer:$media3")
|
||||||
|
implementation("androidx.media3:media3-exoplayer-hls:$media3")
|
||||||
implementation("androidx.media3:media3-ui:$media3")
|
implementation("androidx.media3:media3-ui:$media3")
|
||||||
implementation("androidx.media3:media3-datasource-okhttp:$media3")
|
implementation("androidx.media3:media3-datasource-okhttp:$media3")
|
||||||
implementation("io.github.anilbeesetti:nextlib-media3ext:1.10.1-0.13.0")
|
implementation("io.github.anilbeesetti:nextlib-media3ext:1.10.1-0.13.0")
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,8 @@ class ApiClient(
|
||||||
logoUrl = o.optString("logoUrl").ifBlank { null },
|
logoUrl = o.optString("logoUrl").ifBlank { null },
|
||||||
streamUrl = o.getString("streamUrl"),
|
streamUrl = o.getString("streamUrl"),
|
||||||
format = o.optString("format").ifBlank { "hls" },
|
format = o.optString("format").ifBlank { "hls" },
|
||||||
|
fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null },
|
||||||
|
fallbackFormat = o.optString("fallbackFormat").ifBlank { null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -487,6 +489,8 @@ data class IptvPlayInfo(
|
||||||
val logoUrl: String? = null,
|
val logoUrl: String? = null,
|
||||||
val streamUrl: String,
|
val streamUrl: String,
|
||||||
val format: String,
|
val format: String,
|
||||||
|
val fallbackStreamUrl: String? = null,
|
||||||
|
val fallbackFormat: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EpgProgram(
|
data class EpgProgram(
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,22 @@ import androidx.media3.common.MimeTypes
|
||||||
import androidx.media3.common.TrackSelectionOverride
|
import androidx.media3.common.TrackSelectionOverride
|
||||||
import androidx.media3.common.Tracks
|
import androidx.media3.common.Tracks
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.Renderer
|
import androidx.media3.exoplayer.Renderer
|
||||||
import androidx.media3.exoplayer.audio.AudioRendererEventListener
|
import androidx.media3.exoplayer.audio.AudioRendererEventListener
|
||||||
import androidx.media3.exoplayer.audio.AudioSink
|
import androidx.media3.exoplayer.audio.AudioSink
|
||||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||||
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
|
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
|
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hardware-video + FFmpeg-audio (prefer).
|
* Hardware-video + FFmpeg-audio (prefer).
|
||||||
|
|
@ -80,6 +85,62 @@ private class McRenderersFactory(context: Context) : NextRenderersFactory(contex
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live IPTV: hardware-video, audio hardware-first met FFmpeg-fallback (MP2/AC3/E-AC3).
|
||||||
|
* PREFER vermijden — scheelt CPU; ON pakt FFmpeg alleen als MediaCodec faalt.
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
private class LiveRenderersFactory(context: Context) : NextRenderersFactory(context) {
|
||||||
|
init {
|
||||||
|
setEnableDecoderFallback(true)
|
||||||
|
setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun buildAudioRenderers(
|
||||||
|
context: Context,
|
||||||
|
extensionRendererMode: Int,
|
||||||
|
mediaCodecSelector: MediaCodecSelector,
|
||||||
|
enableDecoderFallback: Boolean,
|
||||||
|
audioSink: AudioSink,
|
||||||
|
eventHandler: Handler,
|
||||||
|
eventListener: AudioRendererEventListener,
|
||||||
|
out: ArrayList<Renderer>,
|
||||||
|
) {
|
||||||
|
super.buildAudioRenderers(
|
||||||
|
context,
|
||||||
|
EXTENSION_RENDERER_MODE_ON,
|
||||||
|
mediaCodecSelector,
|
||||||
|
enableDecoderFallback,
|
||||||
|
audioSink,
|
||||||
|
eventHandler,
|
||||||
|
eventListener,
|
||||||
|
out,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun buildVideoRenderers(
|
||||||
|
context: Context,
|
||||||
|
extensionRendererMode: Int,
|
||||||
|
mediaCodecSelector: MediaCodecSelector,
|
||||||
|
enableDecoderFallback: Boolean,
|
||||||
|
eventHandler: Handler,
|
||||||
|
eventListener: VideoRendererEventListener,
|
||||||
|
allowedVideoJoiningTimeMs: Long,
|
||||||
|
out: ArrayList<Renderer>,
|
||||||
|
) {
|
||||||
|
super.buildVideoRenderers(
|
||||||
|
context,
|
||||||
|
EXTENSION_RENDERER_MODE_OFF,
|
||||||
|
mediaCodecSelector,
|
||||||
|
enableDecoderFallback,
|
||||||
|
eventHandler,
|
||||||
|
eventListener,
|
||||||
|
allowedVideoJoiningTimeMs,
|
||||||
|
out,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
fun buildTvPlayer(context: Context): ExoPlayer {
|
fun buildTvPlayer(context: Context): ExoPlayer {
|
||||||
val trackSelector = DefaultTrackSelector(context).apply {
|
val trackSelector = DefaultTrackSelector(context).apply {
|
||||||
|
|
@ -110,6 +171,74 @@ fun buildTvPlayer(context: Context): ExoPlayer {
|
||||||
.also { it.volume = 1f }
|
.also { it.volume = 1f }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Gedeelde HTTP-client voor HLS-segmenten (connectie-hergebruik). */
|
||||||
|
private val liveOkHttp: OkHttpClient by lazy {
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.retryOnConnectionFailure(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live IPTV: TS-first, kleine buffer, lage latency. */
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun buildLiveTvPlayer(context: Context): ExoPlayer {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val dataSourceFactory = OkHttpDataSource.Factory(liveOkHttp)
|
||||||
|
.setUserAgent("VMC-AndroidTV")
|
||||||
|
|
||||||
|
val trackSelector = DefaultTrackSelector(appContext).apply {
|
||||||
|
parameters = buildUponParameters()
|
||||||
|
.setExceedRendererCapabilitiesIfNecessary(true)
|
||||||
|
.setAllowAudioMixedMimeTypeAdaptiveness(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
val loadControl = DefaultLoadControl.Builder()
|
||||||
|
.setBufferDurationsMs(
|
||||||
|
/* minBufferMs = */ 8_000,
|
||||||
|
/* maxBufferMs = */ 20_000,
|
||||||
|
/* bufferForPlaybackMs = */ 1_800,
|
||||||
|
/* bufferForPlaybackAfterRebufferMs = */ 2_500,
|
||||||
|
)
|
||||||
|
.setPrioritizeTimeOverSizeThresholds(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val renderersFactory = LiveRenderersFactory(appContext)
|
||||||
|
|
||||||
|
val audioAttributes = AudioAttributes.Builder()
|
||||||
|
.setUsage(C.USAGE_MEDIA)
|
||||||
|
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return ExoPlayer.Builder(appContext, renderersFactory)
|
||||||
|
.setTrackSelector(trackSelector)
|
||||||
|
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
|
||||||
|
.setLoadControl(loadControl)
|
||||||
|
.setAudioAttributes(audioAttributes, /* handleAudioFocus = */ true)
|
||||||
|
.setHandleAudioBecomingNoisy(true)
|
||||||
|
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||||
|
.build()
|
||||||
|
.also { it.volume = 1f }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TS = geen live-offset (laagste latency). HLS-fallback ~2s achter live-rand. */
|
||||||
|
fun liveMediaItem(url: String, format: String): MediaItem {
|
||||||
|
val isHls = format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true)
|
||||||
|
val mime = if (isHls) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP2T
|
||||||
|
val builder = MediaItem.Builder().setUri(url).setMimeType(mime)
|
||||||
|
if (isHls) {
|
||||||
|
builder.setLiveConfiguration(
|
||||||
|
MediaItem.LiveConfiguration.Builder()
|
||||||
|
.setTargetOffsetMs(2_000)
|
||||||
|
.setMinOffsetMs(1_000)
|
||||||
|
.setMaxOffsetMs(5_000)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
data class PlayerTrack(val groupIndex: Int, val trackIndex: Int, val label: String, val type: Int)
|
data class PlayerTrack(val groupIndex: Int, val trackIndex: Int, val label: String, val type: Int)
|
||||||
|
|
||||||
fun ExoPlayer.listTracks(type: Int): List<PlayerTrack> {
|
fun ExoPlayer.listTracks(type: Int): List<PlayerTrack> {
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.media3.common.MediaItem
|
|
||||||
import androidx.media3.common.MimeTypes
|
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
|
|
@ -54,8 +52,9 @@ import coil.compose.AsyncImage
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||||
import nl.vonas.mediacluster.tv.data.IptvEpg
|
import nl.vonas.mediacluster.tv.data.IptvEpg
|
||||||
import nl.vonas.mediacluster.tv.playback.buildTvPlayer
|
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
|
||||||
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
|
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
|
||||||
|
import nl.vonas.mediacluster.tv.playback.liveMediaItem
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
|
@ -66,7 +65,7 @@ fun LiveTvPlayerScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val player = remember { buildTvPlayer(context) }
|
val player = remember { buildLiveTvPlayer(context) }
|
||||||
val focus = remember { FocusRequester() }
|
val focus = remember { FocusRequester() }
|
||||||
|
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
|
@ -75,28 +74,47 @@ fun LiveTvPlayerScreen(
|
||||||
var logoUrl by remember { mutableStateOf<String?>(null) }
|
var logoUrl by remember { mutableStateOf<String?>(null) }
|
||||||
var epg by remember { mutableStateOf<IptvEpg?>(null) }
|
var epg by remember { mutableStateOf<IptvEpg?>(null) }
|
||||||
var overlay by remember { mutableStateOf(true) }
|
var overlay by remember { mutableStateOf(true) }
|
||||||
|
var fallbackUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
var fallbackFormat by remember { mutableStateOf<String?>(null) }
|
||||||
|
var triedFallback by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun startStream(url: String, format: String) {
|
||||||
|
player.setMediaItem(liveMediaItem(url, format))
|
||||||
|
player.prepare()
|
||||||
|
player.playWhenReady = true
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(streamId) {
|
LaunchedEffect(streamId) {
|
||||||
loading = true
|
loading = true
|
||||||
error = null
|
error = null
|
||||||
|
triedFallback = false
|
||||||
|
epg = null
|
||||||
try {
|
try {
|
||||||
val play = api.iptvPlayUrl(streamId)
|
val play = api.iptvPlayUrl(streamId)
|
||||||
channelName = play.name
|
channelName = play.name
|
||||||
logoUrl = play.logoUrl
|
logoUrl = play.logoUrl
|
||||||
val mime = when {
|
// API levert classic TS eerst; lokaal nog TS forceren als fallback in response zit
|
||||||
play.format.equals("hls", true) || play.streamUrl.contains(".m3u8", true) ->
|
val tsUrl = when {
|
||||||
MimeTypes.APPLICATION_M3U8
|
play.format.equals("ts", ignoreCase = true) -> play.streamUrl
|
||||||
else -> MimeTypes.VIDEO_MP2T
|
play.fallbackFormat?.equals("ts", ignoreCase = true) == true -> play.fallbackStreamUrl
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val hlsUrl = when {
|
||||||
|
play.format.equals("hls", ignoreCase = true) -> play.streamUrl
|
||||||
|
play.fallbackFormat?.equals("hls", ignoreCase = true) == true -> play.fallbackStreamUrl
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (!tsUrl.isNullOrBlank()) {
|
||||||
|
fallbackUrl = hlsUrl ?: play.fallbackStreamUrl
|
||||||
|
fallbackFormat = (hlsUrl ?: play.fallbackStreamUrl)?.let {
|
||||||
|
if (it.contains("m3u8", true)) "hls" else play.fallbackFormat
|
||||||
|
}
|
||||||
|
startStream(tsUrl, "ts")
|
||||||
|
} else {
|
||||||
|
fallbackUrl = play.fallbackStreamUrl
|
||||||
|
fallbackFormat = play.fallbackFormat
|
||||||
|
startStream(play.streamUrl, play.format)
|
||||||
}
|
}
|
||||||
player.setMediaItem(
|
|
||||||
MediaItem.Builder()
|
|
||||||
.setUri(play.streamUrl)
|
|
||||||
.setMimeType(mime)
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
player.prepare()
|
|
||||||
player.playWhenReady = true
|
|
||||||
runCatching { epg = api.iptvEpg(streamId) }
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = e.message ?: "Kon zender niet starten"
|
error = e.message ?: "Kon zender niet starten"
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -104,6 +122,11 @@ fun LiveTvPlayerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(streamId) {
|
||||||
|
delay(2_500)
|
||||||
|
runCatching { epg = api.iptvEpg(streamId) }
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(overlay) {
|
LaunchedEffect(overlay) {
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
delay(5000)
|
delay(5000)
|
||||||
|
|
@ -118,6 +141,14 @@ fun LiveTvPlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlayerError(e: PlaybackException) {
|
override fun onPlayerError(e: PlaybackException) {
|
||||||
|
val fb = fallbackUrl
|
||||||
|
val fbFmt = fallbackFormat
|
||||||
|
if (!triedFallback && !fb.isNullOrBlank()) {
|
||||||
|
triedFallback = true
|
||||||
|
error = null
|
||||||
|
startStream(fb, fbFmt ?: "hls")
|
||||||
|
return
|
||||||
|
}
|
||||||
error = e.message ?: "Afspeelfout"
|
error = e.message ?: "Afspeelfout"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -77,6 +78,13 @@ fun McNav() {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var ready by remember { mutableStateOf(false) }
|
var ready by remember { mutableStateOf(false) }
|
||||||
var authed by remember { mutableStateOf(false) }
|
var authed by remember { mutableStateOf(false) }
|
||||||
|
// Blijft behouden als main uit compositie gaat (bijv. live player)
|
||||||
|
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
|
||||||
|
var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var tvLastStreamId by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||||
|
val hubTab = remember(hubTabName) {
|
||||||
|
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
val base = store.apiBase.first()
|
val base = store.apiBase.first()
|
||||||
|
|
@ -101,6 +109,12 @@ fun McNav() {
|
||||||
composable("main") {
|
composable("main") {
|
||||||
MainHub(
|
MainHub(
|
||||||
api = api,
|
api = api,
|
||||||
|
tab = hubTab,
|
||||||
|
onTabChange = { hubTabName = it.name },
|
||||||
|
tvCategoryId = tvCategoryId,
|
||||||
|
onTvCategoryChange = { tvCategoryId = it },
|
||||||
|
tvLastStreamId = tvLastStreamId,
|
||||||
|
onTvLastStreamChange = { tvLastStreamId = it },
|
||||||
onOpenMovie = { nav.navigate("movie/$it") },
|
onOpenMovie = { nav.navigate("movie/$it") },
|
||||||
onOpenSeries = { nav.navigate("series/$it") },
|
onOpenSeries = { nav.navigate("series/$it") },
|
||||||
onBrowse = { catalogId, _title ->
|
onBrowse = { catalogId, _title ->
|
||||||
|
|
@ -112,7 +126,11 @@ fun McNav() {
|
||||||
val s = seriesId?.ifBlank { null } ?: "-"
|
val s = seriesId?.ifBlank { null } ?: "-"
|
||||||
nav.navigate("player/$mediaFileId/$type/$m/$e/$s/$startMs")
|
nav.navigate("player/$mediaFileId/$type/$m/$e/$s/$startMs")
|
||||||
},
|
},
|
||||||
onPlayLive = { streamId -> nav.navigate("live/$streamId") },
|
onPlayLive = { streamId ->
|
||||||
|
tvLastStreamId = streamId
|
||||||
|
if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name
|
||||||
|
nav.navigate("live/$streamId")
|
||||||
|
},
|
||||||
onLogout = {
|
onLogout = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
store.setToken(null)
|
store.setToken(null)
|
||||||
|
|
@ -208,6 +226,12 @@ fun McNav() {
|
||||||
@Composable
|
@Composable
|
||||||
private fun MainHub(
|
private fun MainHub(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
|
tab: MainTab,
|
||||||
|
onTabChange: (MainTab) -> Unit,
|
||||||
|
tvCategoryId: String?,
|
||||||
|
onTvCategoryChange: (String?) -> Unit,
|
||||||
|
tvLastStreamId: Int?,
|
||||||
|
onTvLastStreamChange: (Int?) -> Unit,
|
||||||
onOpenMovie: (String) -> Unit,
|
onOpenMovie: (String) -> Unit,
|
||||||
onOpenSeries: (String) -> Unit,
|
onOpenSeries: (String) -> Unit,
|
||||||
onBrowse: (catalogId: String, title: String) -> Unit,
|
onBrowse: (catalogId: String, title: String) -> Unit,
|
||||||
|
|
@ -215,7 +239,6 @@ private fun MainHub(
|
||||||
onPlayLive: (Int) -> Unit,
|
onPlayLive: (Int) -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var tab by remember { mutableStateOf(MainTab.Home) }
|
|
||||||
var iptvEnabled by remember { mutableStateOf(false) }
|
var iptvEnabled by remember { mutableStateOf(false) }
|
||||||
var navFocused by remember { mutableStateOf(false) }
|
var navFocused by remember { mutableStateOf(false) }
|
||||||
var exitConfirm by remember { mutableStateOf(false) }
|
var exitConfirm by remember { mutableStateOf(false) }
|
||||||
|
|
@ -304,12 +327,12 @@ private fun MainHub(
|
||||||
}
|
}
|
||||||
// Films/Series/Zoeken: BrowseScreen handelt scroll→top af; hier alleen top-nav → Home
|
// Films/Series/Zoeken: BrowseScreen handelt scroll→top af; hier alleen top-nav → Home
|
||||||
BackHandler(enabled = navFocused && tab != MainTab.Home && !exitConfirm) {
|
BackHandler(enabled = navFocused && tab != MainTab.Home && !exitConfirm) {
|
||||||
tab = MainTab.Home
|
onTabChange(MainTab.Home)
|
||||||
focusTopNav()
|
focusTopNav()
|
||||||
}
|
}
|
||||||
// Settings (geen eigen scroll-back): terug naar home
|
// Settings (geen eigen scroll-back): terug naar home
|
||||||
BackHandler(enabled = tab == MainTab.Settings && !navFocused && !exitConfirm) {
|
BackHandler(enabled = tab == MainTab.Settings && !navFocused && !exitConfirm) {
|
||||||
tab = MainTab.Home
|
onTabChange(MainTab.Home)
|
||||||
focusTopNav()
|
focusTopNav()
|
||||||
}
|
}
|
||||||
// TV: BrowseScreen-achtig — content scrollt terug, daarna top-nav
|
// TV: BrowseScreen-achtig — content scrollt terug, daarna top-nav
|
||||||
|
|
@ -332,7 +355,7 @@ private fun MainHub(
|
||||||
visibleTabs = visibleTabs,
|
visibleTabs = visibleTabs,
|
||||||
onSelect = {
|
onSelect = {
|
||||||
if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
|
if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
|
||||||
tab = it
|
onTabChange(it)
|
||||||
exitConfirm = false
|
exitConfirm = false
|
||||||
focusTopNav()
|
focusTopNav()
|
||||||
when (it) {
|
when (it) {
|
||||||
|
|
@ -355,7 +378,7 @@ private fun MainHub(
|
||||||
selectedSubId = selectedSubId,
|
selectedSubId = selectedSubId,
|
||||||
onSelectSub = { item ->
|
onSelectSub = { item ->
|
||||||
if (item.id == "__search__") {
|
if (item.id == "__search__") {
|
||||||
tab = MainTab.Search
|
onTabChange(MainTab.Search)
|
||||||
focusTopNav()
|
focusTopNav()
|
||||||
} else {
|
} else {
|
||||||
when (tab) {
|
when (tab) {
|
||||||
|
|
@ -401,6 +424,10 @@ private fun MainHub(
|
||||||
)
|
)
|
||||||
MainTab.Tv -> TvScreen(
|
MainTab.Tv -> TvScreen(
|
||||||
api = api,
|
api = api,
|
||||||
|
selectedCategoryId = tvCategoryId,
|
||||||
|
onCategoryChange = onTvCategoryChange,
|
||||||
|
lastStreamId = tvLastStreamId,
|
||||||
|
onLastStreamChange = onTvLastStreamChange,
|
||||||
onAtTopBack = { focusTopNav() },
|
onAtTopBack = { focusTopNav() },
|
||||||
onPlayChannel = onPlayLive,
|
onPlayChannel = onPlayLive,
|
||||||
onContentFocused = { navFocused = false },
|
onContentFocused = { navFocused = false },
|
||||||
|
|
@ -414,7 +441,7 @@ private fun MainHub(
|
||||||
MainTab.Settings -> SettingsScreen(
|
MainTab.Settings -> SettingsScreen(
|
||||||
api = api,
|
api = api,
|
||||||
onBack = {
|
onBack = {
|
||||||
tab = MainTab.Home
|
onTabChange(MainTab.Home)
|
||||||
focusTopNav()
|
focusTopNav()
|
||||||
},
|
},
|
||||||
onLogout = onLogout,
|
onLogout = onLogout,
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,10 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -24,6 +26,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -55,18 +58,22 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
@Composable
|
@Composable
|
||||||
fun TvScreen(
|
fun TvScreen(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
|
selectedCategoryId: String?,
|
||||||
|
onCategoryChange: (String?) -> Unit,
|
||||||
|
lastStreamId: Int? = null,
|
||||||
|
onLastStreamChange: (Int?) -> Unit = {},
|
||||||
onAtTopBack: () -> Unit,
|
onAtTopBack: () -> Unit,
|
||||||
onPlayChannel: (streamId: Int) -> Unit,
|
onPlayChannel: (streamId: Int) -> Unit,
|
||||||
onContentFocused: () -> Unit = {},
|
onContentFocused: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var categories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) }
|
var categories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) }
|
||||||
var channels by remember { mutableStateOf<List<IptvChannel>>(emptyList()) }
|
var channels by remember { mutableStateOf<List<IptvChannel>>(emptyList()) }
|
||||||
var selectedCategoryId by remember { mutableStateOf<String?>(null) }
|
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var loadingChannels by remember { mutableStateOf(false) }
|
var loadingChannels by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
|
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
|
||||||
var epgJob by remember { mutableStateOf<Job?>(null) }
|
var epgJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
val gridState = rememberSaveable(saver = LazyGridState.Saver) { LazyGridState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|
@ -75,7 +82,12 @@ fun TvScreen(
|
||||||
try {
|
try {
|
||||||
val cats = api.iptvCategories()
|
val cats = api.iptvCategories()
|
||||||
categories = cats
|
categories = cats
|
||||||
selectedCategoryId = cats.firstOrNull()?.id
|
val restored = selectedCategoryId?.takeIf { id -> cats.any { it.id == id } }
|
||||||
|
if (restored != null) {
|
||||||
|
onCategoryChange(restored)
|
||||||
|
} else {
|
||||||
|
onCategoryChange(cats.firstOrNull()?.id)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = e.message ?: "Kon TV niet laden"
|
error = e.message ?: "Kon TV niet laden"
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -97,6 +109,15 @@ fun TvScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(channels, lastStreamId) {
|
||||||
|
val target = lastStreamId ?: return@LaunchedEffect
|
||||||
|
val idx = channels.indexOfFirst { it.streamId == target }
|
||||||
|
if (idx >= 0) {
|
||||||
|
gridState.scrollToItem(idx)
|
||||||
|
runCatching { focusedEpg = api.iptvEpg(target) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadEpg(streamId: Int) {
|
fun loadEpg(streamId: Int) {
|
||||||
epgJob?.cancel()
|
epgJob?.cancel()
|
||||||
epgJob = scope.launch {
|
epgJob = scope.launch {
|
||||||
|
|
@ -127,7 +148,7 @@ fun TvScreen(
|
||||||
CategorySidebar(
|
CategorySidebar(
|
||||||
categories = categories,
|
categories = categories,
|
||||||
selectedId = selectedCategoryId,
|
selectedId = selectedCategoryId,
|
||||||
onSelect = { selectedCategoryId = it },
|
onSelect = { onCategoryChange(it) },
|
||||||
onFocused = onContentFocused,
|
onFocused = onContentFocused,
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -166,6 +187,7 @@ fun TvScreen(
|
||||||
} else {
|
} else {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(5),
|
columns = GridCells.Fixed(5),
|
||||||
|
state = gridState,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
|
@ -174,9 +196,13 @@ fun TvScreen(
|
||||||
items(channels, key = { it.streamId }) { ch ->
|
items(channels, key = { it.streamId }) { ch ->
|
||||||
ChannelTile(
|
ChannelTile(
|
||||||
channel = ch,
|
channel = ch,
|
||||||
onClick = { onPlayChannel(ch.streamId) },
|
onClick = {
|
||||||
|
onLastStreamChange(ch.streamId)
|
||||||
|
onPlayChannel(ch.streamId)
|
||||||
|
},
|
||||||
onFocused = {
|
onFocused = {
|
||||||
onContentFocused()
|
onContentFocused()
|
||||||
|
onLastStreamChange(ch.streamId)
|
||||||
loadEpg(ch.streamId)
|
loadEpg(ch.streamId)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -255,10 +255,10 @@ export class XtreamClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* XUI 1.5+ playlists use /play/{token}/m3u8 instead of classic /live/user/pass/id.
|
* XUI playlists: output=hls | ts | llhls (LLOD low-latency, XUI 1.5+).
|
||||||
* Real live channels often hang on classic URLs; created_live (24/7) still works classically.
|
* On-demand + LLOD v3 levert HLS-segmenten — geen raw TS continuous stream.
|
||||||
*/
|
*/
|
||||||
async getLivePlayUrlMap(output: "hls" | "ts" = "hls"): Promise<Map<number, string>> {
|
async getLivePlayUrlMap(output: "hls" | "ts" | "llhls" = "hls"): Promise<Map<number, string>> {
|
||||||
const cacheKey = `playlist:${output}`;
|
const cacheKey = `playlist:${output}`;
|
||||||
const cached = this.cacheGet<Map<number, string>>(cacheKey, 5 * 60_000);
|
const cached = this.cacheGet<Map<number, string>>(cacheKey, 5 * 60_000);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
@ -279,28 +279,61 @@ export class XtreamClient {
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prefer XUI /play/token URL; fall back to classic Xtream path. */
|
/** Normaliseer XUI /play/{token} naar expliciet /m3u8 of /ts pad. */
|
||||||
async resolveLivePlayUrls(streamId: number): Promise<{ hls?: string; ts?: string; classic: string[] }> {
|
private normalizePlayUrl(url: string, kind: "hls" | "ts"): string {
|
||||||
|
const qIdx = url.indexOf("?");
|
||||||
|
const path = qIdx >= 0 ? url.slice(0, qIdx) : url;
|
||||||
|
const query = qIdx >= 0 ? url.slice(qIdx) : "";
|
||||||
|
if (kind === "hls") {
|
||||||
|
if (/\/m3u8$/i.test(path)) return url;
|
||||||
|
if (/\/play\/[^/]+$/i.test(path)) return `${path}/m3u8${query}`;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
if (/\/ts$/i.test(path)) return url;
|
||||||
|
if (/\/m3u8$/i.test(path)) return `${path.replace(/\/m3u8$/i, "/ts")}${query}`;
|
||||||
|
if (/\/play\/[^/]+$/i.test(path)) return `${path}/ts${query}`;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** XUI on-demand LLOD: llhls → hls playlist → classic m3u8 → ts. */
|
||||||
|
async resolveLivePlayUrls(streamId: number): Promise<{
|
||||||
|
llhls?: string;
|
||||||
|
hls?: string;
|
||||||
|
ts?: string;
|
||||||
|
classic: string[];
|
||||||
|
}> {
|
||||||
|
let llhls: string | undefined;
|
||||||
|
try {
|
||||||
|
const map = await this.getLivePlayUrlMap("llhls");
|
||||||
|
const raw = map.get(streamId);
|
||||||
|
if (raw) llhls = this.normalizePlayUrl(raw, "hls");
|
||||||
|
} catch {
|
||||||
|
// llhls niet beschikbaar op deze XUI-build
|
||||||
|
}
|
||||||
|
|
||||||
let hls: string | undefined;
|
let hls: string | undefined;
|
||||||
try {
|
try {
|
||||||
const hlsMap = await this.getLivePlayUrlMap("hls");
|
const map = await this.getLivePlayUrlMap("hls");
|
||||||
hls = hlsMap.get(streamId);
|
const raw = map.get(streamId);
|
||||||
|
if (raw) hls = this.normalizePlayUrl(raw, "hls");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — fall back
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
let ts: string | undefined;
|
let ts: string | undefined;
|
||||||
if (hls) {
|
try {
|
||||||
if (/\/m3u8$/i.test(hls)) {
|
const map = await this.getLivePlayUrlMap("ts");
|
||||||
ts = hls.replace(/\/m3u8$/i, "/ts");
|
const raw = map.get(streamId);
|
||||||
} else if (/\/play\/[^/]+$/i.test(hls)) {
|
if (raw) ts = this.normalizePlayUrl(raw, "ts");
|
||||||
const base = hls.replace(/\/$/, "");
|
} catch {
|
||||||
hls = `${base}/m3u8`;
|
// ignore
|
||||||
ts = `${base}/ts`;
|
|
||||||
}
|
}
|
||||||
|
if (!ts && hls) {
|
||||||
|
ts = hls.replace(/\/m3u8(\?.*)?$/i, "/ts$1");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
llhls,
|
||||||
hls,
|
hls,
|
||||||
ts,
|
ts,
|
||||||
classic: [
|
classic: [
|
||||||
|
|
|
||||||
|
|
@ -147,15 +147,32 @@ export async function getViewerIptvPlayUrl(
|
||||||
if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
|
if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
|
||||||
|
|
||||||
const resolved = await xt.client.resolveLivePlayUrls(streamId);
|
const resolved = await xt.client.resolveLivePlayUrls(streamId);
|
||||||
const url = resolved.hls || resolved.ts || resolved.classic[0];
|
|
||||||
if (!url) throw new AppError("UNAVAILABLE", "Geen afspeel-URL voor deze zender", 503);
|
// Smarters-gedrag: klassiek Xtream-pad eerst (/live/user/pass/id.ts), daarna playlist-fallbacks
|
||||||
|
const classicTs = resolved.classic.find((u) => /\.ts$/i.test(u));
|
||||||
|
const classicHls = resolved.classic.find((u) => /\.m3u8$/i.test(u) || u.includes("m3u8"));
|
||||||
|
const playlistHls = resolved.hls ?? resolved.llhls;
|
||||||
|
const playlistTs = resolved.ts;
|
||||||
|
|
||||||
|
const candidates = [classicTs, playlistTs, classicHls, playlistHls, ...resolved.classic].filter(
|
||||||
|
(u): u is string => Boolean(u)
|
||||||
|
);
|
||||||
|
const unique = [...new Set(candidates)];
|
||||||
|
const primary = unique[0];
|
||||||
|
if (!primary) throw new AppError("UNAVAILABLE", "Geen afspeel-URL voor deze zender", 503);
|
||||||
|
const fallback = unique[1];
|
||||||
|
|
||||||
|
const formatFor = (url: string): "hls" | "ts" =>
|
||||||
|
url.includes(".m3u8") || url.includes("m3u8") ? "hls" : "ts";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
streamId,
|
streamId,
|
||||||
name: ch.name,
|
name: ch.name,
|
||||||
logoUrl: ch.stream_icon || null,
|
logoUrl: ch.stream_icon || null,
|
||||||
streamUrl: url,
|
streamUrl: primary,
|
||||||
format: url.includes(".m3u8") || url.includes("m3u8") ? "hls" : "ts",
|
format: formatFor(primary),
|
||||||
|
fallbackStreamUrl: fallback ?? null,
|
||||||
|
fallbackFormat: fallback ? formatFor(fallback) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue