From 19ecc33c0781a3f46735681d579a80351139ce18 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Sun, 17 May 2026 20:05:18 -0400 Subject: [PATCH] Fix series auto-next playback state --- app/build.gradle.kts | 4 +- .../example/xtreamplayer/MainActivityUi.kt | 100 +++++++++++++----- .../com/example/xtreamplayer/PlayerScreen.kt | 20 +++- 3 files changed, 96 insertions(+), 28 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b91a03d..7b221e7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,8 +3,8 @@ import java.util.Properties import org.gradle.api.provider.Property import org.jetbrains.kotlin.gradle.dsl.JvmTarget -val appVersionCode = 143 -val appVersionName = "3.4.7" +val appVersionCode = 144 +val appVersionName = "3.4.8" plugins { alias(libs.plugins.android.application) diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index ad64f1f..add8fbd 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -1125,6 +1125,16 @@ fun RootScreen( } } + fun syncPlaybackStateToQueueIndex(index: Int): Boolean { + val queue = activePlaybackQueue ?: return false + if (index !in queue.items.indices) return false + activePlaybackItems.getOrNull(index)?.let { activePlaybackItem = it } + activePlaybackTitle = queue.items[index].title + activePlaybackSubtitleState = null + resumePositionMs = null + return true + } + fun startPlayback( item: ContentItem, items: List, @@ -1132,9 +1142,11 @@ fun RootScreen( parentItem: ContentItem? = null, playbackResumePositionMs: Long? = null ) { - val playableItems = items.filter(::isPlayableContent) + val playableItems = playableContentItemsForQueue(items, item) + val queue = buildPlaybackQueue(playableItems, item, config) + val startItem = playableItems.getOrNull(queue.startIndex) ?: item activePlaybackItems = playableItems - activePlaybackItem = item + activePlaybackItem = startItem activePlaybackSeriesParent = if (item.contentType == ContentType.SERIES) { parentItem @@ -1149,9 +1161,8 @@ fun RootScreen( BufferProfile.VOD } playbackEngine.setBufferProfile(profile) - val queue = buildPlaybackQueue(items, item, config) activePlaybackQueue = queue - activePlaybackTitle = queue.items.getOrNull(queue.startIndex)?.title ?: item.title + activePlaybackTitle = queue.items.getOrNull(queue.startIndex)?.title ?: startItem.title } val handlePlayItem: (ContentItem, List) -> Unit = { item, items -> @@ -1160,12 +1171,7 @@ fun RootScreen( resumeFocusId = resolveResumeFocusTarget(item) val shouldResolveSeriesQueue = item.contentType == ContentType.SERIES && - !item.containerExtension.isNullOrBlank() && - (items.size <= 1 || - items.any { - it.contentType != ContentType.SERIES || - it.containerExtension.isNullOrBlank() - }) + !item.containerExtension.isNullOrBlank() if (shouldResolveSeriesQueue) { coroutineScope.launch { val resolved = @@ -1274,12 +1280,7 @@ fun RootScreen( resumeFocusId = resolveResumeFocusTarget(item) val shouldResolveSeriesQueue = item.contentType == ContentType.SERIES && - !item.containerExtension.isNullOrBlank() && - (items.size <= 1 || - items.any { - it.contentType != ContentType.SERIES || - it.containerExtension.isNullOrBlank() - }) + !item.containerExtension.isNullOrBlank() if (shouldResolveSeriesQueue) { coroutineScope.launch { val resolved = @@ -1387,9 +1388,16 @@ fun RootScreen( completionThresholdPercent = CONTINUE_WATCHING_MAX_PROGRESS_PERCENT ) val subtitleState = activePlaybackSubtitleState + val hasFollowingSeriesEpisode = + item.contentType == ContentType.SERIES && + activePlaybackQueue?.items + ?.drop(safePlayer.currentMediaItemIndex + 1) + ?.any { it.type == ContentType.SERIES } == true coroutineScope.launch { if (!shouldStore) { - continueWatchingRepository.removeEntry(config, item) + if (!hasFollowingSeriesEpisode) { + continueWatchingRepository.removeEntry(config, item) + } vodPlaybackStateRepository.removeEntry(config, queueMediaIdFor(item)) } else { val parentItem = @@ -1619,6 +1627,14 @@ fun RootScreen( syncPlaybackStateFromPlayer(mediaItem) } + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + syncPlaybackStateFromPlayer(newPosition.mediaItem) + } + override fun onPlaybackStateChanged(playbackState: Int) { syncPlaybackStateFromPlayer() if (playbackState == Player.STATE_READY) { @@ -2294,8 +2310,15 @@ fun RootScreen( resumePositionMs = null }, onPlayNextEpisode = { - playbackEngine.player.seekToNextMediaItem() - playbackEngine.player.playWhenReady = true + val player = playbackEngine.player + savePlaybackProgress() + val nextIndex = player.currentMediaItemIndex + 1 + if (syncPlaybackStateToQueueIndex(nextIndex)) { + player.seekTo(nextIndex, 0L) + } else { + player.seekToNextMediaItem() + } + player.playWhenReady = true }, onMatchFrameRateChange = { enabled -> playbackEngine.applySettings(settings.copy(matchFrameRateEnabled = enabled)) @@ -2718,6 +2741,17 @@ private fun isPlayableContent(item: ContentItem): Boolean { return item.contentType != ContentType.SERIES || !item.containerExtension.isNullOrBlank() } +private fun playableContentItemsForQueue( + items: List, + current: ContentItem +): List { + val playableItems = items.filter(::isPlayableContent).toMutableList() + if (playableItems.none { isSameContentIdentity(it, current) }) { + playableItems.add(current) + } + return playableItems +} + private fun isSameContentIdentity(first: ContentItem, second: ContentItem): Boolean { if (first.contentType != second.contentType) return false val firstStreamId = first.streamId?.takeUnless { it.isBlank() } @@ -2739,10 +2773,7 @@ private fun buildPlaybackQueue( current: ContentItem, authConfig: AuthConfig ): PlaybackQueue { - val playableItems = items.filter(::isPlayableContent).toMutableList() - if (playableItems.none { isSameContentIdentity(it, current) }) { - playableItems.add(current) - } + val playableItems = playableContentItemsForQueue(items, current) val startIndex = playableItems.indexOfFirst { isSameContentIdentity(it, current) }.let { index -> if (index >= 0) index else 0 @@ -8746,10 +8777,13 @@ fun ContinueWatchingScreen( } val columns = rememberReflowColumns(baseColumns, navLayoutExpanded) val resolvedParents = remember { androidx.compose.runtime.mutableStateMapOf() } + val parentResolutionAttempted = + remember { androidx.compose.runtime.mutableStateMapOf() } val posterFontScale = remember(columns) { 4f / columns.toFloat() } val resolvedParentsSnapshot = resolvedParents.toMap() + val parentResolutionAttemptedSnapshot = parentResolutionAttempted.toMap() val displayEntries = - remember(continueWatchingItems, resolvedParentsSnapshot) { + remember(continueWatchingItems, resolvedParentsSnapshot, parentResolutionAttemptedSnapshot) { fun displayGroupingKey(entry: ContinueWatchingEntry): String { val canonicalSeriesId = entry.parentItem?.streamId?.takeUnless { it.isBlank() } @@ -8786,6 +8820,16 @@ fun ContinueWatchingScreen( } .maxByOrNull { it.first } ?.second + val waitingForParentResolution = + group.any { entry -> + entry.item.contentType == ContentType.SERIES && + entry.parentItem == null && + !resolvedParentsSnapshot.containsKey(entry.key) && + parentResolutionAttemptedSnapshot[entry.key] != true + } + if (latestSeriesParent == null && waitingForParentResolution) { + return@mapNotNull null + } val displayItem = latestSeriesParent ?: latest.item val resumeLabel = if (latest.item.contentType == ContentType.SERIES) { @@ -8856,16 +8900,22 @@ fun ContinueWatchingScreen( .filterNot { it in currentKeys } .toList() .forEach { resolvedParents.remove(it) } + parentResolutionAttempted.keys + .filterNot { it in currentKeys } + .toList() + .forEach { parentResolutionAttempted.remove(it) } continueWatchingItems .filter { entry -> entry.item.contentType == ContentType.SERIES && entry.parentItem == null && - !resolvedParents.containsKey(entry.key) + !resolvedParents.containsKey(entry.key) && + parentResolutionAttempted[entry.key] != true } .forEach { entry -> val resolved = resolveSeriesParent(entry.item) if (resolved != null) { resolvedParents[entry.key] = resolved } + parentResolutionAttempted[entry.key] = true } } diff --git a/app/src/main/java/com/example/xtreamplayer/PlayerScreen.kt b/app/src/main/java/com/example/xtreamplayer/PlayerScreen.kt index 490187e..a696a35 100644 --- a/app/src/main/java/com/example/xtreamplayer/PlayerScreen.kt +++ b/app/src/main/java/com/example/xtreamplayer/PlayerScreen.kt @@ -1,6 +1,12 @@ package com.example.xtreamplayer import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.media3.common.Player import com.example.xtreamplayer.content.CategoryItem import com.example.xtreamplayer.content.ContentItem import com.example.xtreamplayer.content.ContentType @@ -32,7 +38,19 @@ internal fun PlayerScreen( loadLiveCategoryThumbnail: suspend (CategoryItem) -> Result ) { val queue = activePlaybackQueue ?: return - val currentIndex = playbackEngine.player.currentMediaItemIndex + val player = playbackEngine.player + var currentIndex by remember(queue, player) { mutableIntStateOf(player.currentMediaItemIndex) } + DisposableEffect(queue, player) { + currentIndex = player.currentMediaItemIndex + val listener = + object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + currentIndex = player.currentMediaItemIndex + } + } + player.addListener(listener) + onDispose { player.removeListener(listener) } + } val queueItems = queue.items val hasNextEpisode = currentIndex >= 0 && currentIndex < queueItems.size - 1 val nextEpisodeTitle = queueItems.getOrNull(currentIndex + 1)?.title