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"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 22
|
||||
versionName = "0.9.0"
|
||||
versionCode = 33
|
||||
versionName = "0.11.1"
|
||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
|
|
@ -59,6 +59,7 @@ dependencies {
|
|||
// Media3 + FFmpeg-audio (DTS/TrueHD/E-AC3 → PCM) via nextlib
|
||||
val media3 = "1.10.1"
|
||||
implementation("androidx.media3:media3-exoplayer:$media3")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:$media3")
|
||||
implementation("androidx.media3:media3-ui:$media3")
|
||||
implementation("androidx.media3:media3-datasource-okhttp:$media3")
|
||||
implementation("io.github.anilbeesetti:nextlib-media3ext:1.10.1-0.13.0")
|
||||
|
|
|
|||
|
|
@ -314,6 +314,8 @@ class ApiClient(
|
|||
logoUrl = o.optString("logoUrl").ifBlank { null },
|
||||
streamUrl = o.getString("streamUrl"),
|
||||
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 streamUrl: String,
|
||||
val format: String,
|
||||
val fallbackStreamUrl: String? = null,
|
||||
val fallbackFormat: String? = null,
|
||||
)
|
||||
|
||||
data class EpgProgram(
|
||||
|
|
|
|||
|
|
@ -10,17 +10,22 @@ import androidx.media3.common.MimeTypes
|
|||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.Tracks
|
||||
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.Renderer
|
||||
import androidx.media3.exoplayer.audio.AudioRendererEventListener
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||
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_ON
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* 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)
|
||||
fun buildTvPlayer(context: Context): ExoPlayer {
|
||||
val trackSelector = DefaultTrackSelector(context).apply {
|
||||
|
|
@ -110,6 +171,74 @@ fun buildTvPlayer(context: Context): ExoPlayer {
|
|||
.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)
|
||||
|
||||
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.sp
|
||||
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.Player
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
|
|
@ -54,8 +52,9 @@ import coil.compose.AsyncImage
|
|||
import kotlinx.coroutines.delay
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
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.liveMediaItem
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
|
|
@ -66,7 +65,7 @@ fun LiveTvPlayerScreen(
|
|||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val player = remember { buildTvPlayer(context) }
|
||||
val player = remember { buildLiveTvPlayer(context) }
|
||||
val focus = remember { FocusRequester() }
|
||||
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
|
|
@ -75,28 +74,47 @@ fun LiveTvPlayerScreen(
|
|||
var logoUrl by remember { mutableStateOf<String?>(null) }
|
||||
var epg by remember { mutableStateOf<IptvEpg?>(null) }
|
||||
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) {
|
||||
loading = true
|
||||
error = null
|
||||
triedFallback = false
|
||||
epg = null
|
||||
try {
|
||||
val play = api.iptvPlayUrl(streamId)
|
||||
channelName = play.name
|
||||
logoUrl = play.logoUrl
|
||||
val mime = when {
|
||||
play.format.equals("hls", true) || play.streamUrl.contains(".m3u8", true) ->
|
||||
MimeTypes.APPLICATION_M3U8
|
||||
else -> MimeTypes.VIDEO_MP2T
|
||||
// API levert classic TS eerst; lokaal nog TS forceren als fallback in response zit
|
||||
val tsUrl = when {
|
||||
play.format.equals("ts", ignoreCase = true) -> play.streamUrl
|
||||
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) {
|
||||
error = e.message ?: "Kon zender niet starten"
|
||||
} finally {
|
||||
|
|
@ -104,6 +122,11 @@ fun LiveTvPlayerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(streamId) {
|
||||
delay(2_500)
|
||||
runCatching { epg = api.iptvEpg(streamId) }
|
||||
}
|
||||
|
||||
LaunchedEffect(overlay) {
|
||||
if (overlay) {
|
||||
delay(5000)
|
||||
|
|
@ -118,6 +141,14 @@ fun LiveTvPlayerScreen(
|
|||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -77,6 +78,13 @@ fun McNav() {
|
|||
val scope = rememberCoroutineScope()
|
||||
var ready 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) {
|
||||
val base = store.apiBase.first()
|
||||
|
|
@ -101,6 +109,12 @@ fun McNav() {
|
|||
composable("main") {
|
||||
MainHub(
|
||||
api = api,
|
||||
tab = hubTab,
|
||||
onTabChange = { hubTabName = it.name },
|
||||
tvCategoryId = tvCategoryId,
|
||||
onTvCategoryChange = { tvCategoryId = it },
|
||||
tvLastStreamId = tvLastStreamId,
|
||||
onTvLastStreamChange = { tvLastStreamId = it },
|
||||
onOpenMovie = { nav.navigate("movie/$it") },
|
||||
onOpenSeries = { nav.navigate("series/$it") },
|
||||
onBrowse = { catalogId, _title ->
|
||||
|
|
@ -112,7 +126,11 @@ fun McNav() {
|
|||
val s = seriesId?.ifBlank { null } ?: "-"
|
||||
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 = {
|
||||
scope.launch {
|
||||
store.setToken(null)
|
||||
|
|
@ -208,6 +226,12 @@ fun McNav() {
|
|||
@Composable
|
||||
private fun MainHub(
|
||||
api: ApiClient,
|
||||
tab: MainTab,
|
||||
onTabChange: (MainTab) -> Unit,
|
||||
tvCategoryId: String?,
|
||||
onTvCategoryChange: (String?) -> Unit,
|
||||
tvLastStreamId: Int?,
|
||||
onTvLastStreamChange: (Int?) -> Unit,
|
||||
onOpenMovie: (String) -> Unit,
|
||||
onOpenSeries: (String) -> Unit,
|
||||
onBrowse: (catalogId: String, title: String) -> Unit,
|
||||
|
|
@ -215,7 +239,6 @@ private fun MainHub(
|
|||
onPlayLive: (Int) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
) {
|
||||
var tab by remember { mutableStateOf(MainTab.Home) }
|
||||
var iptvEnabled by remember { mutableStateOf(false) }
|
||||
var navFocused 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
|
||||
BackHandler(enabled = navFocused && tab != MainTab.Home && !exitConfirm) {
|
||||
tab = MainTab.Home
|
||||
onTabChange(MainTab.Home)
|
||||
focusTopNav()
|
||||
}
|
||||
// Settings (geen eigen scroll-back): terug naar home
|
||||
BackHandler(enabled = tab == MainTab.Settings && !navFocused && !exitConfirm) {
|
||||
tab = MainTab.Home
|
||||
onTabChange(MainTab.Home)
|
||||
focusTopNav()
|
||||
}
|
||||
// TV: BrowseScreen-achtig — content scrollt terug, daarna top-nav
|
||||
|
|
@ -332,7 +355,7 @@ private fun MainHub(
|
|||
visibleTabs = visibleTabs,
|
||||
onSelect = {
|
||||
if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
|
||||
tab = it
|
||||
onTabChange(it)
|
||||
exitConfirm = false
|
||||
focusTopNav()
|
||||
when (it) {
|
||||
|
|
@ -355,7 +378,7 @@ private fun MainHub(
|
|||
selectedSubId = selectedSubId,
|
||||
onSelectSub = { item ->
|
||||
if (item.id == "__search__") {
|
||||
tab = MainTab.Search
|
||||
onTabChange(MainTab.Search)
|
||||
focusTopNav()
|
||||
} else {
|
||||
when (tab) {
|
||||
|
|
@ -401,6 +424,10 @@ private fun MainHub(
|
|||
)
|
||||
MainTab.Tv -> TvScreen(
|
||||
api = api,
|
||||
selectedCategoryId = tvCategoryId,
|
||||
onCategoryChange = onTvCategoryChange,
|
||||
lastStreamId = tvLastStreamId,
|
||||
onLastStreamChange = onTvLastStreamChange,
|
||||
onAtTopBack = { focusTopNav() },
|
||||
onPlayChannel = onPlayLive,
|
||||
onContentFocused = { navFocused = false },
|
||||
|
|
@ -414,7 +441,7 @@ private fun MainHub(
|
|||
MainTab.Settings -> SettingsScreen(
|
||||
api = api,
|
||||
onBack = {
|
||||
tab = MainTab.Home
|
||||
onTabChange(MainTab.Home)
|
||||
focusTopNav()
|
||||
},
|
||||
onLogout = onLogout,
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -24,6 +26,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -55,18 +58,22 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors
|
|||
@Composable
|
||||
fun TvScreen(
|
||||
api: ApiClient,
|
||||
selectedCategoryId: String?,
|
||||
onCategoryChange: (String?) -> Unit,
|
||||
lastStreamId: Int? = null,
|
||||
onLastStreamChange: (Int?) -> Unit = {},
|
||||
onAtTopBack: () -> Unit,
|
||||
onPlayChannel: (streamId: Int) -> Unit,
|
||||
onContentFocused: () -> Unit = {},
|
||||
) {
|
||||
var categories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) }
|
||||
var channels by remember { mutableStateOf<List<IptvChannel>>(emptyList()) }
|
||||
var selectedCategoryId by remember { mutableStateOf<String?>(null) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var loadingChannels by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
|
||||
var epgJob by remember { mutableStateOf<Job?>(null) }
|
||||
val gridState = rememberSaveable(saver = LazyGridState.Saver) { LazyGridState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -75,7 +82,12 @@ fun TvScreen(
|
|||
try {
|
||||
val cats = api.iptvCategories()
|
||||
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) {
|
||||
error = e.message ?: "Kon TV niet laden"
|
||||
} 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) {
|
||||
epgJob?.cancel()
|
||||
epgJob = scope.launch {
|
||||
|
|
@ -127,7 +148,7 @@ fun TvScreen(
|
|||
CategorySidebar(
|
||||
categories = categories,
|
||||
selectedId = selectedCategoryId,
|
||||
onSelect = { selectedCategoryId = it },
|
||||
onSelect = { onCategoryChange(it) },
|
||||
onFocused = onContentFocused,
|
||||
)
|
||||
Column(
|
||||
|
|
@ -166,6 +187,7 @@ fun TvScreen(
|
|||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(5),
|
||||
state = gridState,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
|
|
@ -174,9 +196,13 @@ fun TvScreen(
|
|||
items(channels, key = { it.streamId }) { ch ->
|
||||
ChannelTile(
|
||||
channel = ch,
|
||||
onClick = { onPlayChannel(ch.streamId) },
|
||||
onClick = {
|
||||
onLastStreamChange(ch.streamId)
|
||||
onPlayChannel(ch.streamId)
|
||||
},
|
||||
onFocused = {
|
||||
onContentFocused()
|
||||
onLastStreamChange(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.
|
||||
* Real live channels often hang on classic URLs; created_live (24/7) still works classically.
|
||||
* XUI playlists: output=hls | ts | llhls (LLOD low-latency, XUI 1.5+).
|
||||
* 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 cached = this.cacheGet<Map<number, string>>(cacheKey, 5 * 60_000);
|
||||
if (cached) return cached;
|
||||
|
|
@ -279,28 +279,61 @@ export class XtreamClient {
|
|||
return map;
|
||||
}
|
||||
|
||||
/** Prefer XUI /play/token URL; fall back to classic Xtream path. */
|
||||
async resolveLivePlayUrls(streamId: number): Promise<{ hls?: string; ts?: string; classic: string[] }> {
|
||||
/** Normaliseer XUI /play/{token} naar expliciet /m3u8 of /ts pad. */
|
||||
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;
|
||||
try {
|
||||
const hlsMap = await this.getLivePlayUrlMap("hls");
|
||||
hls = hlsMap.get(streamId);
|
||||
const map = await this.getLivePlayUrlMap("hls");
|
||||
const raw = map.get(streamId);
|
||||
if (raw) hls = this.normalizePlayUrl(raw, "hls");
|
||||
} catch {
|
||||
// ignore — fall back
|
||||
// ignore
|
||||
}
|
||||
|
||||
let ts: string | undefined;
|
||||
if (hls) {
|
||||
if (/\/m3u8$/i.test(hls)) {
|
||||
ts = hls.replace(/\/m3u8$/i, "/ts");
|
||||
} else if (/\/play\/[^/]+$/i.test(hls)) {
|
||||
const base = hls.replace(/\/$/, "");
|
||||
hls = `${base}/m3u8`;
|
||||
ts = `${base}/ts`;
|
||||
try {
|
||||
const map = await this.getLivePlayUrlMap("ts");
|
||||
const raw = map.get(streamId);
|
||||
if (raw) ts = this.normalizePlayUrl(raw, "ts");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!ts && hls) {
|
||||
ts = hls.replace(/\/m3u8(\?.*)?$/i, "/ts$1");
|
||||
}
|
||||
|
||||
return {
|
||||
llhls,
|
||||
hls,
|
||||
ts,
|
||||
classic: [
|
||||
|
|
|
|||
|
|
@ -147,15 +147,32 @@ export async function getViewerIptvPlayUrl(
|
|||
if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
|
||||
|
||||
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 {
|
||||
streamId,
|
||||
name: ch.name,
|
||||
logoUrl: ch.stream_icon || null,
|
||||
streamUrl: url,
|
||||
format: url.includes(".m3u8") || url.includes("m3u8") ? "hls" : "ts",
|
||||
streamUrl: primary,
|
||||
format: formatFor(primary),
|
||||
fallbackStreamUrl: fallback ?? null,
|
||||
fallbackFormat: fallback ? formatFor(fallback) : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue