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:
Jos Vooges | STH 2026-09-02 00:06:02 +02:00
parent 5524b3269d
commit fe4b1210ac
8 changed files with 318 additions and 50 deletions

View file

@ -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")

View file

@ -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(

View file

@ -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> {

View file

@ -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"
} }
} }

View file

@ -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,

View file

@ -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)
}, },
) )

View file

@ -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: [

View file

@ -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,
}; };
} }