From a2e7753f7631b3f87584932b03773055aec6bf68 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Sun, 17 May 2026 14:58:49 -0400 Subject: [PATCH] Fix playback identity and episode resume state --- .../example/xtreamplayer/MainActivityUi.kt | 609 +++++++++++++----- .../PlaybackSubtitlePersistence.kt | 11 + .../com/example/xtreamplayer/api/XtreamApi.kt | 32 +- .../xtreamplayer/content/ContentCache.kt | 4 +- .../xtreamplayer/content/ContentItem.kt | 3 +- .../xtreamplayer/content/ContentRepository.kt | 10 + .../content/ContinueWatchingRepository.kt | 101 ++- .../content/FavoritesRepository.kt | 6 +- .../content/VodPlaybackStateRepository.kt | 210 ++++++ 9 files changed, 804 insertions(+), 182 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/content/VodPlaybackStateRepository.kt diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index 390a24f..f49c316 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -142,6 +142,7 @@ import com.example.xtreamplayer.content.resolveContinueWatchingMinWatchMs import com.example.xtreamplayer.content.SeriesInfo import com.example.xtreamplayer.content.SearchNormalizer import com.example.xtreamplayer.content.SubtitleRepository +import com.example.xtreamplayer.content.VodPlaybackStateRepository import com.example.xtreamplayer.content.shouldStoreContinueWatchingEntry import com.example.xtreamplayer.observability.AppDiagnostics import com.example.xtreamplayer.player.Media3PlaybackEngine @@ -291,6 +292,7 @@ fun RootScreen( val savedConfig by authViewModel.savedConfig.collectAsStateWithLifecycle() val savedConfigLoaded by authViewModel.savedConfigLoaded.collectAsStateWithLifecycle() val localPlaybackResumeRepository = remember { LocalPlaybackResumeRepository(context) } + val vodPlaybackStateRepository = remember { VodPlaybackStateRepository(context) } val localPlaybackResumeEntries by localPlaybackResumeRepository.entries.collectAsStateWithLifecycle(initialValue = emptyList()) val localResumeByMediaId = @@ -1088,27 +1090,104 @@ fun RootScreen( } } + fun resolveActivePlaybackItem(player: ExoPlayer?, mediaItem: MediaItem? = null): ContentItem? { + val transitionedMediaId = mediaItem?.mediaId?.takeUnless { it.isBlank() } + if (transitionedMediaId != null) { + activePlaybackItems.firstOrNull { queueMediaIdFor(it) == transitionedMediaId }?.let { + return it + } + } + if (player == null) return activePlaybackItem + val currentMediaId = player.currentMediaItem?.mediaId?.takeUnless { it.isBlank() } + if (currentMediaId != null) { + activePlaybackItems.firstOrNull { queueMediaIdFor(it) == currentMediaId }?.let { + return it + } + } + val currentIndex = player.currentMediaItemIndex + if (currentIndex in activePlaybackItems.indices) { + return activePlaybackItems[currentIndex] + } + return activePlaybackItem + } + + fun syncPlaybackStateFromPlayer(mediaItem: MediaItem? = playbackEngine.player.currentMediaItem) { + val player = playbackEngine.player + val resolvedItem = resolveActivePlaybackItem(player, mediaItem) + if (resolvedItem != null && resolvedItem != activePlaybackItem) { + activePlaybackItem = resolvedItem + } + val resolvedTitle = + mediaItem?.mediaMetadata?.title?.toString()?.takeUnless { it.isBlank() } + ?: resolvedItem?.title + if (!resolvedTitle.isNullOrBlank() && resolvedTitle != activePlaybackTitle) { + activePlaybackTitle = resolvedTitle + } + } + + fun startPlayback( + item: ContentItem, + items: List, + config: AuthConfig, + parentItem: ContentItem? = null, + playbackResumePositionMs: Long? = null + ) { + val playableItems = items.filter(::isPlayableContent) + activePlaybackItems = playableItems + activePlaybackItem = item + activePlaybackSeriesParent = + if (item.contentType == ContentType.SERIES) { + parentItem + } else { + null + } + resumePositionMs = playbackResumePositionMs?.takeIf { it > 0 } + val profile = + if (item.contentType == ContentType.LIVE) { + BufferProfile.LIVE + } else { + BufferProfile.VOD + } + playbackEngine.setBufferProfile(profile) + val queue = buildPlaybackQueue(items, item, config) + activePlaybackQueue = queue + activePlaybackTitle = queue.items.getOrNull(queue.startIndex)?.title ?: item.title + } + val handlePlayItem: (ContentItem, List) -> Unit = { item, items -> val config = authState.activeConfig if (config != null) { resumeFocusId = resolveResumeFocusTarget(item) - val playableItems = items.filter(::isPlayableContent) - activePlaybackItems = playableItems - activePlaybackItem = item - if (item.contentType != ContentType.SERIES) { - activePlaybackSeriesParent = null - } - val profile = - if (item.contentType == ContentType.LIVE) { - BufferProfile.LIVE - } else { - BufferProfile.VOD - } - playbackEngine.setBufferProfile(profile) - val queue = buildPlaybackQueue(items, item, config) - activePlaybackQueue = queue - activePlaybackTitle = queue.items.getOrNull(queue.startIndex)?.title ?: item.title - coroutineScope.launch { historyRepository.addToHistory(config, item) } + val shouldResolveSeriesQueue = + item.contentType == ContentType.SERIES && + !item.containerExtension.isNullOrBlank() && + (items.size <= 1 || + items.any { + it.contentType != ContentType.SERIES || + it.containerExtension.isNullOrBlank() + }) + if (shouldResolveSeriesQueue) { + coroutineScope.launch { + val resolved = + resolveCanonicalSeriesPlaybackItems( + contentRepository = contentRepository, + authConfig = config, + item = item, + fallbackItems = items, + parentHint = activePlaybackSeriesParent + ) + startPlayback(item = item, items = resolved.items, config = config, parentItem = resolved.parentItem) + historyRepository.addToHistory(config, item) + } + } else { + startPlayback( + item = item, + items = items, + config = config, + parentItem = activePlaybackSeriesParent + ) + coroutineScope.launch { historyRepository.addToHistory(config, item) } + } } } @@ -1153,18 +1232,38 @@ fun RootScreen( if (config != null) { resumeFocusId = resolveResumeFocusTarget(item) val items = listOf(item) - val playableItems = items.filter(::isPlayableContent) - activePlaybackItems = playableItems - activePlaybackItem = item - if (item.contentType != ContentType.SERIES) { - activePlaybackSeriesParent = null + val shouldResolveSeriesQueue = + item.contentType == ContentType.SERIES && + !item.containerExtension.isNullOrBlank() + if (shouldResolveSeriesQueue) { + coroutineScope.launch { + val resolved = + resolveCanonicalSeriesPlaybackItems( + contentRepository = contentRepository, + authConfig = config, + item = item, + fallbackItems = items, + parentHint = activePlaybackSeriesParent + ) + startPlayback( + item = item, + items = resolved.items, + config = config, + parentItem = resolved.parentItem, + playbackResumePositionMs = positionMs + ) + historyRepository.addToHistory(config, item) + } + } else { + startPlayback( + item = item, + items = items, + config = config, + parentItem = activePlaybackSeriesParent, + playbackResumePositionMs = positionMs + ) + coroutineScope.launch { historyRepository.addToHistory(config, item) } } - resumePositionMs = if (positionMs > 0) positionMs else null - playbackEngine.setBufferProfile(BufferProfile.VOD) - val queue = buildPlaybackQueue(items, item, config) - activePlaybackQueue = queue - activePlaybackTitle = queue.items.getOrNull(queue.startIndex)?.title ?: item.title - coroutineScope.launch { historyRepository.addToHistory(config, item) } } } @@ -1173,24 +1272,43 @@ fun RootScreen( val config = authState.activeConfig if (config != null) { resumeFocusId = resolveResumeFocusTarget(item) - val playableItems = items.filter(::isPlayableContent) - activePlaybackItems = playableItems - activePlaybackItem = item - if (item.contentType != ContentType.SERIES) { - activePlaybackSeriesParent = null + val shouldResolveSeriesQueue = + item.contentType == ContentType.SERIES && + !item.containerExtension.isNullOrBlank() && + (items.size <= 1 || + items.any { + it.contentType != ContentType.SERIES || + it.containerExtension.isNullOrBlank() + }) + if (shouldResolveSeriesQueue) { + coroutineScope.launch { + val resolved = + resolveCanonicalSeriesPlaybackItems( + contentRepository = contentRepository, + authConfig = config, + item = item, + fallbackItems = items, + parentHint = activePlaybackSeriesParent + ) + startPlayback( + item = item, + items = resolved.items, + config = config, + parentItem = resolved.parentItem, + playbackResumePositionMs = positionMs + ) + coroutineScope.launch { historyRepository.addToHistory(config, item) } + } + } else { + startPlayback( + item = item, + items = items, + config = config, + parentItem = activePlaybackSeriesParent, + playbackResumePositionMs = positionMs + ) + coroutineScope.launch { historyRepository.addToHistory(config, item) } } - resumePositionMs = positionMs?.takeIf { it > 0 } - val profile = - if (item.contentType == ContentType.LIVE) { - BufferProfile.LIVE - } else { - BufferProfile.VOD - } - playbackEngine.setBufferProfile(profile) - val queue = buildPlaybackQueue(items, item, config) - activePlaybackQueue = queue - activePlaybackTitle = queue.items.getOrNull(queue.startIndex)?.title ?: item.title - coroutineScope.launch { historyRepository.addToHistory(config, item) } } } @@ -1214,7 +1332,7 @@ fun RootScreen( } } - val activeContinueWatchingEntryFlow = + val activeVodPlaybackStateEntryFlow = remember( activeConfig, activePlaybackItem?.id, @@ -1226,27 +1344,28 @@ fun RootScreen( if (config == null || item == null) { flowOf(null) } else { - continueWatchingRepository.continueWatchingEntryForContent(config, item) + vodPlaybackStateRepository.entryForMediaId(config, queueMediaIdFor(item)) } } - val activeContinueWatchingEntry by - activeContinueWatchingEntryFlow.collectAsStateWithLifecycle(initialValue = null) + val activeVodPlaybackStateEntry by + activeVodPlaybackStateEntryFlow.collectAsStateWithLifecycle(initialValue = null) LaunchedEffect( activePlaybackItem?.id, activePlaybackItem?.contentType, - activeContinueWatchingEntry?.subtitleFileName, - activeContinueWatchingEntry?.subtitleLanguage, - activeContinueWatchingEntry?.subtitleLabel, - activeContinueWatchingEntry?.subtitleOffsetMs + activeVodPlaybackStateEntry?.subtitleFileName, + activeVodPlaybackStateEntry?.subtitleLanguage, + activeVodPlaybackStateEntry?.subtitleLabel, + activeVodPlaybackStateEntry?.subtitleOffsetMs ) { - activePlaybackSubtitleState = activeContinueWatchingEntry?.toPlaybackSubtitleStateOrNull() + activePlaybackSubtitleState = activeVodPlaybackStateEntry?.toPlaybackSubtitleStateOrNull() } fun savePlaybackProgress() { val player: ExoPlayer? = playbackEngine.player val safePlayer = player ?: return + syncPlaybackStateFromPlayer(safePlayer.currentMediaItem) val config = authState.activeConfig - val item = activePlaybackItem + val item = resolveActivePlaybackItem(safePlayer) if (config != null && item != null && (item.contentType == ContentType.MOVIES || @@ -1271,6 +1390,7 @@ fun RootScreen( coroutineScope.launch { if (!shouldStore) { continueWatchingRepository.removeEntry(config, item) + vodPlaybackStateRepository.removeEntry(config, queueMediaIdFor(item)) } else { val parentItem = if (item.contentType == ContentType.SERIES) { @@ -1289,8 +1409,23 @@ fun RootScreen( subtitleLabel = subtitleState?.label, subtitleOffsetMs = subtitleState?.offsetMs ?: 0L ) + vodPlaybackStateRepository.updateProgress( + config = config, + mediaId = queueMediaIdFor(item), + title = item.title, + positionMs = position, + durationMs = duration, + subtitleFileName = subtitleState?.fileName, + subtitleLanguage = subtitleState?.language, + subtitleLabel = subtitleState?.label, + subtitleOffsetMs = subtitleState?.offsetMs ?: 0L + ) } } + } else { + coroutineScope.launch { + vodPlaybackStateRepository.removeEntry(config, queueMediaIdFor(item)) + } } } @@ -1325,7 +1460,6 @@ fun RootScreen( } val lifecycleOwner = LocalLifecycleOwner.current - val latestPlaybackItem by rememberUpdatedState(activePlaybackItem) val latestPlaybackQueue by rememberUpdatedState(activePlaybackQueue) DisposableEffect(lifecycleOwner) { @@ -1333,7 +1467,8 @@ fun RootScreen( val player: ExoPlayer? = playbackEngine.player when (event) { Lifecycle.Event.ON_STOP -> { - val item = latestPlaybackItem + syncPlaybackStateFromPlayer(player?.currentMediaItem) + val item = resolveActivePlaybackItem(player) if (item != null && (item.contentType == ContentType.MOVIES || item.contentType == ContentType.SERIES) @@ -1356,7 +1491,8 @@ fun RootScreen( } } Lifecycle.Event.ON_START -> { - val item = latestPlaybackItem + syncPlaybackStateFromPlayer(player?.currentMediaItem) + val item = resolveActivePlaybackItem(player) val resume = pendingResume if (item != null && resume != null && @@ -1480,43 +1616,17 @@ fun RootScreen( val listener = object : Player.Listener { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - val title = mediaItem?.mediaMetadata?.title?.toString() - if (!title.isNullOrBlank()) { - activePlaybackTitle = title - } - val transitionedMediaId = - mediaItem?.mediaId?.takeUnless { it.isBlank() } - ?: playbackEngine.player.currentMediaItem?.mediaId?.takeUnless { it.isBlank() } - if (!transitionedMediaId.isNullOrBlank()) { - activePlaybackItems.firstOrNull { item -> - queueMediaIdFor(item) == transitionedMediaId - }?.let { matchedItem -> - activePlaybackItem = matchedItem - return - } - } - val currentIndex = playbackEngine.player.currentMediaItemIndex - if (currentIndex < 0 || activePlaybackItems.isEmpty()) return - val queueItems = activePlaybackQueue?.items - if (queueItems != null && queueItems.size != activePlaybackItems.size) { - Timber.w( - "Playback queue mismatch: queue=${queueItems.size} items=${activePlaybackItems.size} index=$currentIndex" - ) - return - } - val safeIndex = currentIndex.coerceIn(0, activePlaybackItems.lastIndex) - if (safeIndex != currentIndex) { - Timber.w("Clamped playback index $currentIndex to $safeIndex") - } - activePlaybackItem = activePlaybackItems[safeIndex] + syncPlaybackStateFromPlayer(mediaItem) } override fun onPlaybackStateChanged(playbackState: Int) { + syncPlaybackStateFromPlayer() if (playbackState == Player.STATE_READY) { playbackRecoveryTracker.markPlaybackHealthy() } if (playbackState == Player.STATE_READY && !playbackEngine.player.isPlaying ) { + syncPlaybackStateFromPlayer() savePlaybackProgress() } if (playbackState == Player.STATE_READY && @@ -2373,6 +2483,159 @@ fun RootScreen( } } +private data class CanonicalPlaybackResolution( + val items: List, + val parentItem: ContentItem? = null +) + +private fun isSeriesEpisodeLaunch(item: ContentItem): Boolean { + return item.contentType == ContentType.SERIES && !item.containerExtension.isNullOrBlank() +} + +private fun canonicalSeriesParentId(item: ContentItem, parentHint: ContentItem?): String? { + return parentHint?.streamId?.takeUnless { it.isBlank() } + ?: parentHint?.id?.takeUnless { it.isBlank() } + ?: item.parentSeriesId?.takeUnless { it.isBlank() } +} + +private fun extractEpisodeSeasonLabel(item: ContentItem): String? { + item.seasonLabel?.takeUnless { it.isBlank() }?.let { return it } + val subtitle = item.subtitle + if (subtitle.isBlank()) return null + val match = Regex("S(\\d+)", RegexOption.IGNORE_CASE).find(subtitle) + return match?.groupValues?.getOrNull(1) +} + +private fun stripEpisodeSuffixes(title: String): String { + var result = title.trim() + if (result.isBlank()) return result + + val suffixPatterns = + listOf( + Regex("""(?:\s*[-–—:|]\s*)?S\d{1,2}E\d{1,3}(?:\s*[-–—:|].*)?$""", RegexOption.IGNORE_CASE), + Regex("""(?:\s*[-–—:|]\s*)?Season\s*\d+(?:\s*[-–—:|].*)?$""", RegexOption.IGNORE_CASE), + Regex("""(?:\s*[-–—:|]\s*)?(?:Episode|Ep\.?)\s*\d+(?:\s*[-–—:|].*)?$""", RegexOption.IGNORE_CASE) + ) + suffixPatterns.forEach { pattern -> + result = result.replace(pattern, "").trim() + } + return result +} + +private fun normalizeSeriesTitleForMatch(title: String): String { + val stripped = stripEpisodeSuffixes(title) + if (stripped.isBlank()) return "" + val decomposed = + java.text.Normalizer.normalize(stripped, java.text.Normalizer.Form.NFKD) + return decomposed + .replace(Regex("\\p{Mn}+"), "") + .replace('ى', 'ي') + .replace('ة', 'ه') + .replace(Regex("\\s+"), " ") + .trim() + .lowercase(java.util.Locale.ROOT) +} + +private suspend fun resolveSeriesParentByTitle( + contentRepository: ContentRepository, + authConfig: AuthConfig, + item: ContentItem +): ContentItem? { + val searchTitle = stripEpisodeSuffixes(item.title) + val normalizedQuery = normalizeSeriesTitleForMatch(searchTitle) + if (normalizedQuery.isBlank()) return null + + val candidates = + runCatching { + contentRepository.searchPage( + section = Section.SERIES, + query = searchTitle, + page = 0, + limit = 20, + authConfig = authConfig + ) + }.getOrNull()?.items.orEmpty() + + val exactMatches = + candidates + .asSequence() + .filter { it.contentType == ContentType.SERIES } + .filter { normalizeSeriesTitleForMatch(it.title) == normalizedQuery } + .distinctBy { it.streamId?.takeUnless { streamId -> streamId.isBlank() } ?: it.id } + .toList() + return exactMatches.singleOrNull() +} + +private suspend fun resolveCanonicalSeriesPlaybackItems( + contentRepository: ContentRepository, + authConfig: AuthConfig, + item: ContentItem, + fallbackItems: List, + parentHint: ContentItem? +): CanonicalPlaybackResolution { + if (!isSeriesEpisodeLaunch(item)) { + return CanonicalPlaybackResolution(items = fallbackItems, parentItem = parentHint) + } + + val stableParentId = canonicalSeriesParentId(item, parentHint) + if (stableParentId.isNullOrBlank()) { + val parentItem = resolveSeriesParentByTitle(contentRepository, authConfig, item) + if (parentItem == null) { + return CanonicalPlaybackResolution(items = fallbackItems) + } + + val allEpisodes = + runCatching { + contentRepository.loadSeriesEpisodes(parentItem.streamId, authConfig) + }.getOrNull().orEmpty() + if (allEpisodes.isEmpty()) { + return CanonicalPlaybackResolution(items = fallbackItems, parentItem = parentItem) + } + + val seasonLabel = extractEpisodeSeasonLabel(item) + val resolvedItems = + if (!seasonLabel.isNullOrBlank()) { + allEpisodes.filter { episode -> + val episodeSeasonLabel = + episode.seasonLabel?.takeUnless { it.isBlank() } + ?: extractEpisodeSeasonLabel(episode) + episodeSeasonLabel == seasonLabel + } + } else { + allEpisodes + } + return CanonicalPlaybackResolution( + items = if (resolvedItems.isEmpty()) allEpisodes else resolvedItems, + parentItem = parentItem + ) + } + + val allEpisodes = + runCatching { + contentRepository.loadSeriesEpisodes(stableParentId, authConfig) + }.getOrNull().orEmpty() + if (allEpisodes.isEmpty()) { + return CanonicalPlaybackResolution(items = fallbackItems, parentItem = parentHint) + } + + val seasonLabel = extractEpisodeSeasonLabel(item) + val resolvedItems = + if (!seasonLabel.isNullOrBlank()) { + allEpisodes.filter { episode -> + val episodeSeasonLabel = + episode.seasonLabel?.takeUnless { it.isBlank() } + ?: extractEpisodeSeasonLabel(episode) + episodeSeasonLabel == seasonLabel + } + } else { + allEpisodes + } + return CanonicalPlaybackResolution( + items = if (resolvedItems.isEmpty()) allEpisodes else resolvedItems, + parentItem = parentHint + ) +} + @Composable private fun PlaybackSyncEffects( activePlaybackQueue: PlaybackQueue?, @@ -8482,20 +8745,25 @@ fun ContinueWatchingScreen( kotlin.math.ceil(4.0 / settings.uiScale).toInt().coerceIn(4, 8) } val columns = rememberReflowColumns(baseColumns, navLayoutExpanded) + val resolvedParents = remember { androidx.compose.runtime.mutableStateMapOf() } val posterFontScale = remember(columns) { 4f / columns.toFloat() } + val resolvedParentsSnapshot = resolvedParents.toMap() val displayEntries = - remember(continueWatchingItems) { + remember(continueWatchingItems, resolvedParentsSnapshot) { fun displayGroupingKey(entry: ContinueWatchingEntry): String { - return if (entry.parentItem != null && - entry.item.contentType == ContentType.SERIES + val canonicalSeriesId = + entry.parentItem?.streamId?.takeUnless { it.isBlank() } + ?: entry.parentItem?.id?.takeUnless { it.isBlank() } + ?: resolvedParentsSnapshot[entry.key]?.streamId?.takeUnless { it.isBlank() } + ?: resolvedParentsSnapshot[entry.key]?.id?.takeUnless { it.isBlank() } + ?: entry.item.parentSeriesId?.takeUnless { it.isBlank() } + return if (entry.item.contentType == ContentType.SERIES && + !canonicalSeriesId.isNullOrBlank() ) { - val parentIdentity = - entry.parentItem.streamId?.takeUnless { it.isBlank() } - ?: entry.parentItem.id - "series:$parentIdentity" + "series:$canonicalSeriesId" } else { val itemIdentity = - entry.item.streamId?.takeUnless { it.isBlank() } ?: entry.item.id + entry.item.streamId?.takeUnless { it.isBlank() } ?: entry.item.id "item:${entry.item.contentType.name}:$itemIdentity" } } @@ -8503,9 +8771,22 @@ fun ContinueWatchingScreen( continueWatchingItems.groupBy { entry -> displayGroupingKey(entry) } - grouped.values.mapNotNull { group -> + grouped.entries.mapNotNull { (groupKey, group) -> val latest = group.maxByOrNull { it.timestampMs } ?: return@mapNotNull null - val displayItem = latest.parentItem ?: latest.item + val latestSeriesParent = + group.asSequence() + .filter { it.parentItem != null } + .maxByOrNull { it.timestampMs } + ?.parentItem + ?: group.asSequence() + .mapNotNull { entry -> + resolvedParentsSnapshot[entry.key]?.let { parent -> + entry.timestampMs to parent + } + } + .maxByOrNull { it.first } + ?.second + val displayItem = latestSeriesParent ?: latest.item val resumeLabel = if (latest.item.contentType == ContentType.SERIES) { formatEpisodeLabel(latest.item, separator = " - ")?.let { "Resume $it" } @@ -8519,10 +8800,10 @@ fun ContinueWatchingScreen( displayItem } ContinueWatchingDisplayEntry( - key = latest.key, + key = groupKey, displayItem = displayItemWithSubtitle, resumeItem = latest.item, - parentItem = latest.parentItem, + parentItem = latestSeriesParent ?: latest.parentItem, sourceItems = group.map { it.item }.distinctBy { val identity = it.streamId?.takeUnless { id -> id.isBlank() } ?: it.id @@ -8559,40 +8840,12 @@ fun ContinueWatchingScreen( } } } - val resolvedParents = remember { androidx.compose.runtime.mutableStateMapOf() } val resolveSeriesParent: suspend (ContentItem) -> ContentItem? = { resumeItem -> - val rawTitle = resumeItem.title - val dashSeasonMatch = Regex("\\s-\\sS\\d", RegexOption.IGNORE_CASE).find(rawTitle) - val compactSeasonMatch = Regex("S\\d+E\\d+", RegexOption.IGNORE_CASE).find(rawTitle) - val seriesName = - when { - dashSeasonMatch != null && dashSeasonMatch.range.first > 0 -> - rawTitle.substring(0, dashSeasonMatch.range.first).trim() - compactSeasonMatch != null && compactSeasonMatch.range.first > 0 -> - rawTitle.substring(0, compactSeasonMatch.range.first).trim().trimEnd('-').trim() - else -> rawTitle - } - if (seriesName.isBlank()) { - null + val stableParentId = resumeItem.parentSeriesId?.takeUnless { it.isBlank() } + if (!stableParentId.isNullOrBlank()) { + contentRepository.findSeriesItemById(stableParentId, authConfig) } else { - runCatching { - contentRepository.searchPage( - section = Section.SERIES, - query = seriesName, - page = 0, - limit = 20, - authConfig = authConfig - ) - }.getOrNull()?.items?.firstOrNull { it.title.startsWith(seriesName, ignoreCase = true) } - ?: runCatching { - contentRepository.searchPage( - section = Section.SERIES, - query = seriesName, - page = 0, - limit = 20, - authConfig = authConfig - ) - }.getOrNull()?.items?.firstOrNull() + resolveSeriesParentByTitle(contentRepository, authConfig, resumeItem) } } @@ -8901,9 +9154,6 @@ fun ContinueWatchingScreen( val seriesItem = resolvedParent ?: entry.parentItem - ?: item.takeIf { - it.containerExtension.isNullOrBlank() - } if (seriesItem != null) { returnFocusItem = item pendingResumeItem = entry.resumeItem @@ -9093,6 +9343,14 @@ fun SeriesSeasonsScreen( val episodesTabRequester = contentItemFocusRequester val castTabRequester = tabFocusRequesters[1] val context = LocalContext.current + val vodPlaybackStateRepository = remember { VodPlaybackStateRepository(context) } + val vodPlaybackStateEntries by + vodPlaybackStateRepository.entriesForConfig(authConfig) + .collectAsStateWithLifecycle(initialValue = emptyList()) + val vodPlaybackStateByMediaId = + remember(vodPlaybackStateEntries) { + vodPlaybackStateEntries.associateBy { it.mediaId } + } LaunchedEffect(seriesItem.streamId, focusPlayOnOpen) { withFrameNanos {} @@ -9158,10 +9416,12 @@ fun SeriesSeasonsScreen( null } else { val episodeIds = allEpisodes.map { it.streamId }.toHashSet() - continueWatchingEntries.firstOrNull { entry -> - entry.item.contentType == ContentType.SERIES && - episodeIds.contains(entry.item.streamId) - } + continueWatchingEntries + .filter { entry -> + entry.item.contentType == ContentType.SERIES && + episodeIds.contains(entry.item.streamId) + } + .maxByOrNull { it.timestampMs } } } val continueWatchingEntriesForSeries = @@ -9177,11 +9437,12 @@ fun SeriesSeasonsScreen( } } val resumePositionsById = - remember(continueWatchingEntries) { - continueWatchingEntries - .asSequence() - .filter { it.item.contentType == ContentType.SERIES } - .associate { it.item.id to it.positionMs } + remember(allEpisodes, vodPlaybackStateByMediaId) { + allEpisodes + .associate { episode -> + val mediaId = queueMediaIdFor(episode) + episode.id to (vodPlaybackStateByMediaId[mediaId]?.positionMs ?: 0L) + } } val preferredResumeEpisode = remember(preferredResumeItem, allEpisodes) { @@ -10048,6 +10309,18 @@ fun SeriesSeasonsScreen( } SeriesEpisodeRow( item = item, + fallbackImageUrl = seriesItem.imageUrl, + progressPercent = run { + val playbackState = + vodPlaybackStateByMediaId[queueMediaIdFor(item)] + val positionMs = playbackState?.positionMs?.takeIf { it > 0 } ?: 0L + val durationMs = playbackState?.durationMs?.takeIf { it > 0 } ?: 0L + if (positionMs > 0L && durationMs > 0L) { + ((positionMs * 100L) / durationMs).toInt().coerceIn(0, 100) + } else { + 0 + } + }, focusRequester = requester, forceDarkText = forceDarkText, onActivate = { @@ -10106,6 +10379,8 @@ private enum class SeriesDetailTab { @Composable private fun SeriesEpisodeRow( item: ContentItem, + fallbackImageUrl: String?, + progressPercent: Int, focusRequester: FocusRequester?, forceDarkText: Boolean, onActivate: () -> Unit, @@ -10136,10 +10411,10 @@ private fun SeriesEpisodeRow( val showEpisodeReadMore = description != "No description available." && descriptionOverflow val context = LocalContext.current val imageRequest = - remember(item.imageUrl) { + remember(item.imageUrl, fallbackImageUrl) { buildTvImageRequest( context = context, - imageUrl = item.imageUrl, + imageUrl = item.imageUrl ?: fallbackImageUrl, targetSizePx = 400 ) } @@ -10196,18 +10471,38 @@ private fun SeriesEpisodeRow( Modifier.width(184.dp) .height(104.dp) .clip(RoundedCornerShape(10.dp)) - if (imageRequest != null) { - AsyncImage( - model = imageRequest, - contentDescription = null, - contentScale = ContentScale.Crop, - filterQuality = FilterQuality.Low, - modifier = thumbModifier - ) - } else { - Box( - modifier = thumbModifier.background(colors.surfaceAlt) - ) + Column( + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (imageRequest != null) { + AsyncImage( + model = imageRequest, + contentDescription = null, + contentScale = ContentScale.Crop, + filterQuality = FilterQuality.Low, + modifier = thumbModifier + ) + } else { + Box( + modifier = thumbModifier.background(colors.surfaceAlt) + ) + } + if (progressPercent > 0) { + Box( + modifier = + Modifier.width(184.dp) + .height(4.dp) + .clip(RoundedCornerShape(999.dp)) + .background(colors.overlaySoft) + ) { + Box( + modifier = + Modifier.fillMaxWidth(progressPercent / 100f) + .fillMaxHeight() + .background(colors.accent) + ) + } + } } Column( modifier = Modifier.weight(1f), diff --git a/app/src/main/java/com/example/xtreamplayer/PlaybackSubtitlePersistence.kt b/app/src/main/java/com/example/xtreamplayer/PlaybackSubtitlePersistence.kt index a16077b..37d8bca 100644 --- a/app/src/main/java/com/example/xtreamplayer/PlaybackSubtitlePersistence.kt +++ b/app/src/main/java/com/example/xtreamplayer/PlaybackSubtitlePersistence.kt @@ -1,6 +1,7 @@ package com.example.xtreamplayer import com.example.xtreamplayer.content.ContinueWatchingEntry +import com.example.xtreamplayer.content.VodPlaybackStateEntry internal data class ResolvedSubtitlePersistence( val subtitleFileName: String?, @@ -19,6 +20,16 @@ internal fun ContinueWatchingEntry.toPlaybackSubtitleStateOrNull(): PlaybackSubt ) } +internal fun VodPlaybackStateEntry.toPlaybackSubtitleStateOrNull(): PlaybackSubtitleState? { + val fileName = subtitleFileName?.takeUnless { it.isBlank() || it == "null" } ?: return null + return PlaybackSubtitleState( + fileName = fileName, + language = subtitleLanguage?.takeUnless { it.isBlank() || it == "null" }, + label = subtitleLabel?.takeUnless { it.isBlank() || it == "null" }, + offsetMs = subtitleOffsetMs + ) +} + internal fun resolveSubtitlePersistence( existingEntry: ContinueWatchingEntry?, subtitleFileName: String?, diff --git a/app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt b/app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt index 14956f1..e221bcf 100644 --- a/app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt +++ b/app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt @@ -374,7 +374,7 @@ class XtreamApi( ) body.charStream().use { stream -> val reader = JsonReader(stream) - val allItems = parseSeriesEpisodesAll(reader) + val allItems = parseSeriesEpisodesAll(reader, seriesId) val slice = if (offset >= allItems.size) { emptyList() } else { @@ -492,7 +492,7 @@ class XtreamApi( body.charStream().use { stream -> val reader = JsonReader(stream) val pageData = - parseSeriesSeasonPage(reader, seasonLabel, offset, limit) + parseSeriesSeasonPage(reader, seriesId, seasonLabel, offset, limit) Result.success(pageData) } } @@ -529,7 +529,7 @@ class XtreamApi( ) body.charStream().use { stream -> val reader = JsonReader(stream) - Result.success(parseSeriesSeasonAll(reader, seasonLabel)) + Result.success(parseSeriesSeasonAll(reader, seriesId, seasonLabel)) } } } catch (e: Exception) { @@ -1006,6 +1006,7 @@ class XtreamApi( private fun parseSeriesEpisodes( reader: JsonReader, + seriesId: String, offset: Int, limit: Int ): ContentPage { @@ -1049,7 +1050,7 @@ class XtreamApi( } break } - val item = parseEpisodeItem(reader, seasonKey) + val item = parseEpisodeItem(reader, seriesId, seasonKey) if (item != null) { items.add(item) } @@ -1374,6 +1375,7 @@ class XtreamApi( private fun parseSeriesSeasonPage( reader: JsonReader, + seriesId: String, seasonLabel: String, offset: Int, limit: Int @@ -1409,7 +1411,7 @@ class XtreamApi( } reader.beginArray() while (reader.hasNext()) { - val entry = parseEpisodeEntry(reader, seasonKey) + val entry = parseEpisodeEntry(reader, seriesId, seasonKey) if (entry != null) { if (totalCount >= offset && items.size < limit) { items.add(entry.item) @@ -1432,6 +1434,7 @@ class XtreamApi( private fun parseSeriesSeasonAll( reader: JsonReader, + seriesId: String, seasonLabel: String ): List { if (reader.peek() != JsonToken.BEGIN_OBJECT) { @@ -1463,7 +1466,7 @@ class XtreamApi( } reader.beginArray() while (reader.hasNext()) { - parseEpisodeEntry(reader, seasonKey)?.let { items.add(it.item) } + parseEpisodeEntry(reader, seriesId, seasonKey)?.let { items.add(it.item) } } reader.endArray() } @@ -1473,7 +1476,7 @@ class XtreamApi( return items } - private fun parseSeriesEpisodesAll(reader: JsonReader): List { + private fun parseSeriesEpisodesAll(reader: JsonReader, seriesId: String): List { if (reader.peek() != JsonToken.BEGIN_OBJECT) { reader.skipValue() return emptyList() @@ -1499,7 +1502,7 @@ class XtreamApi( } reader.beginArray() while (reader.hasNext()) { - val entry = parseEpisodeEntry(reader, seasonKey) + val entry = parseEpisodeEntry(reader, seriesId, seasonKey) if (entry != null) { items.add(entry) } @@ -1518,11 +1521,15 @@ class XtreamApi( .map { it.item } } - private fun parseEpisodeItem(reader: JsonReader, seasonLabel: String): ContentItem? { - return parseEpisodeEntry(reader, seasonLabel)?.item + private fun parseEpisodeItem(reader: JsonReader, seriesId: String, seasonLabel: String): ContentItem? { + return parseEpisodeEntry(reader, seriesId, seasonLabel)?.item } - private fun parseEpisodeEntry(reader: JsonReader, seasonLabel: String): EpisodeEntry? { + private fun parseEpisodeEntry( + reader: JsonReader, + seriesId: String, + seasonLabel: String + ): EpisodeEntry? { if (reader.peek() != JsonToken.BEGIN_OBJECT) { reader.skipValue() return null @@ -1571,7 +1578,8 @@ class XtreamApi( duration = episodeInfo?.duration, rating = episodeInfo?.rating, seasonLabel = seasonLabel, - episodeNumber = episodeNum + episodeNumber = episodeNum, + parentSeriesId = seriesId ) return EpisodeEntry( item = item, diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentCache.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentCache.kt index c1bb1de..1e5d111 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentCache.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentCache.kt @@ -787,6 +787,7 @@ class ContentCache(context: Context) { obj.put("seasonLabel", item.seasonLabel) obj.put("episodeNumber", item.episodeNumber) obj.put("categoryId", item.categoryId) + obj.put("parentSeriesId", item.parentSeriesId) array.put(obj) } return array @@ -970,7 +971,8 @@ class ContentCache(context: Context) { rating = itemObj.optString("rating").ifBlank { null }, seasonLabel = itemObj.optString("seasonLabel").ifBlank { null }, episodeNumber = itemObj.optString("episodeNumber").ifBlank { null }, - categoryId = itemObj.optString("categoryId").ifBlank { null } + categoryId = itemObj.optString("categoryId").ifBlank { null }, + parentSeriesId = itemObj.optString("parentSeriesId").ifBlank { null } ) ) } diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentItem.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentItem.kt index 70489ea..3a10ad3 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentItem.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentItem.kt @@ -16,5 +16,6 @@ data class ContentItem( val rating: String? = null, val seasonLabel: String? = null, val episodeNumber: String? = null, - val categoryId: String? = null + val categoryId: String? = null, + val parentSeriesId: String? = null ) diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index 3ea403d..e835793 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -1085,6 +1085,16 @@ class ContentRepository( contentCache.hasSectionIndex(Section.LIVE, authConfig) } + suspend fun findSeriesItemById(seriesId: String, authConfig: AuthConfig): ContentItem? { + if (seriesId.isBlank()) return null + val items = loadSectionIndex(Section.SERIES, authConfig) ?: return null + return items.firstOrNull { item -> + item.contentType == ContentType.SERIES && + item.containerExtension.isNullOrBlank() && + (item.streamId == seriesId || item.id == seriesId) + } + } + private fun shouldKeepSectionIndexInMemory(itemCount: Int): Boolean { return itemCount in 1 until MAX_SECTION_INDEX_ITEMS_IN_MEMORY } diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContinueWatchingRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContinueWatchingRepository.kt index 8ebe3dd..7d585b5 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContinueWatchingRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContinueWatchingRepository.kt @@ -42,15 +42,29 @@ class ContinueWatchingRepository(private val context: Context) { ) { val key = contentKey(config, item) val keysToReplace = contentKeysForUpdate(config, item) + val targetSeriesIdentity = canonicalSeriesIdentity(item, parentItem) val timestampMs = System.currentTimeMillis() context.continueWatchingDataStore.edit { prefs -> val raw = prefs[Keys.CONTINUE_WATCHING_ENTRIES] ?: "[]" val entries = parseAllEntries(raw).toMutableList() - val existingEntry = entries.firstOrNull { it.key in keysToReplace } - - // Remove existing entry with same key - entries.removeAll { it.key in keysToReplace } + val existingEntry = + entries.firstOrNull { entry -> + entry.key in keysToReplace || + isSameCanonicalSeriesIdentity( + canonicalSeriesIdentity(entry.item, entry.parentItem), + targetSeriesIdentity + ) + } + + // Keep a single canonical entry per series and remove stale legacy series cards. + entries.removeAll { entry -> + entry.key in keysToReplace || + isSameCanonicalSeriesIdentity( + canonicalSeriesIdentity(entry.item, entry.parentItem), + targetSeriesIdentity + ) + } val resolvedSubtitlePersistence = resolveSubtitlePersistence( @@ -127,7 +141,7 @@ class ContinueWatchingRepository(private val context: Context) { fun continueWatchingEntriesForConfig(config: AuthConfig): Flow> { return continueWatchingEntries - .map { entries -> entries.filter { isEntryForConfig(it, config) } } + .map { entries -> canonicalizeEntries(entries.filter { isEntryForConfig(it, config) }) } .distinctUntilChanged() } @@ -197,6 +211,7 @@ class ContinueWatchingRepository(private val context: Context) { obj.put("contentType", item.contentType.name) obj.put("streamId", item.streamId) obj.put("containerExtension", item.containerExtension) + obj.put("parentSeriesId", item.parentSeriesId) entry.parentItem?.let { parent -> obj.put("parentId", parent.id) obj.put("parentTitle", parent.title) @@ -206,6 +221,7 @@ class ContinueWatchingRepository(private val context: Context) { obj.put("parentContentType", parent.contentType.name) obj.put("parentStreamId", parent.streamId) obj.put("parentContainerExtension", parent.containerExtension) + obj.put("parentParentSeriesId", parent.parentSeriesId) } entry.subtitleFileName?.let { obj.put("subtitleFileName", it) } entry.subtitleLanguage?.let { obj.put("subtitleLanguage", it) } @@ -241,6 +257,8 @@ class ContinueWatchingRepository(private val context: Context) { .takeUnless { it.isBlank() || it == "null" } val containerExtension = obj.optString("containerExtension") .takeUnless { it.isBlank() || it == "null" } + val parentSeriesId = obj.optString("parentSeriesId") + .takeUnless { it.isBlank() || it == "null" } val streamId = obj.optString("streamId") .takeUnless { it.isBlank() || it == "null" } ?: obj.optString("id") @@ -266,6 +284,8 @@ class ContinueWatchingRepository(private val context: Context) { .takeUnless { it.isBlank() || it == "null" } val parentContainerExtension = obj.optString("parentContainerExtension") .takeUnless { it.isBlank() || it == "null" } + val parentParentSeriesId = obj.optString("parentParentSeriesId") + .takeUnless { it.isBlank() || it == "null" } val parentStreamId = obj.optString("parentStreamId") .takeUnless { it.isBlank() || it == "null" } @@ -278,7 +298,8 @@ class ContinueWatchingRepository(private val context: Context) { section = parentSection, contentType = parentContentType, streamId = parentStreamId, - containerExtension = parentContainerExtension + containerExtension = parentContainerExtension, + parentSeriesId = parentParentSeriesId ) } @@ -293,7 +314,8 @@ class ContinueWatchingRepository(private val context: Context) { section = section, contentType = contentType, streamId = streamId, - containerExtension = containerExtension + containerExtension = containerExtension, + parentSeriesId = parentSeriesId ), positionMs = positionMs, durationMs = durationMs, @@ -334,11 +356,13 @@ class ContinueWatchingRepository(private val context: Context) { } } + val canonicalEntries = canonicalizeEntries(result) + // Partial sort for top 15 - return if (result.size <= 15) { - result.sortedByDescending { it.timestampMs } + return if (canonicalEntries.size <= 15) { + canonicalEntries.sortedByDescending { it.timestampMs } } else { - result.asSequence() + canonicalEntries.asSequence() .sortedByDescending { it.timestampMs } .take(15) .toList() @@ -400,3 +424,60 @@ private fun hasSamePersistedContinueWatchingItem( existing.streamId == incoming.streamId && existing.containerExtension == incoming.containerExtension } + +private fun canonicalSeriesIdentity(item: ContentItem, parentItem: ContentItem?): String? { + if (item.contentType != ContentType.SERIES) return null + return parentItem?.streamId?.takeUnless { it.isBlank() } + ?: parentItem?.id?.takeUnless { it.isBlank() } + ?: item.parentSeriesId?.takeUnless { it.isBlank() } + ?: item.takeIf { it.containerExtension.isNullOrBlank() } + ?.streamId?.takeUnless { it.isBlank() } + ?: item.takeIf { it.containerExtension.isNullOrBlank() }?.id?.takeUnless { it.isBlank() } +} + +private fun isSameCanonicalSeriesIdentity(first: String?, second: String?): Boolean { + return !first.isNullOrBlank() && first == second +} + +private fun isSeriesEpisodeEntry(entry: ContinueWatchingEntry): Boolean { + return entry.item.contentType == ContentType.SERIES && + !entry.item.containerExtension.isNullOrBlank() +} + +private fun mergeSeriesMetadata( + primary: ContinueWatchingEntry, + group: List +): ContinueWatchingEntry { + val latestParentItem = + group.asSequence() + .mapNotNull { it.parentItem?.let { parent -> it.timestampMs to parent } } + .maxByOrNull { it.first } + ?.second + val mergedItem = + if (primary.item.parentSeriesId.isNullOrBlank()) { + primary.item.copy(parentSeriesId = latestParentItem?.streamId ?: latestParentItem?.id) + } else { + primary.item + } + return if (latestParentItem != null || mergedItem !== primary.item) { + primary.copy(item = mergedItem, parentItem = latestParentItem ?: primary.parentItem) + } else { + primary + } +} + +private fun canonicalizeEntries(entries: List): List { + val grouped = LinkedHashMap>() + entries.forEach { entry -> + val key = + canonicalSeriesIdentity(entry.item, entry.parentItem)?.let { "series:$it" } + ?: "item:${entry.item.contentType.name}:${entry.item.streamId?.takeUnless { it.isBlank() } ?: entry.item.id}" + grouped.getOrPut(key) { mutableListOf() }.add(entry) + } + return grouped.values.mapNotNull { group -> + val episodeEntries = group.filter(::isSeriesEpisodeEntry) + val primarySource = if (episodeEntries.isNotEmpty()) episodeEntries else group + val latest = primarySource.maxByOrNull { it.timestampMs } ?: return@mapNotNull null + mergeSeriesMetadata(latest, group) + } +} diff --git a/app/src/main/java/com/example/xtreamplayer/content/FavoritesRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/FavoritesRepository.kt index bbe72e7..991e08c 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/FavoritesRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/FavoritesRepository.kt @@ -137,6 +137,7 @@ class FavoritesRepository(private val context: Context) { obj.put("contentType", item.contentType.name) obj.put("streamId", item.streamId) obj.put("containerExtension", item.containerExtension) + obj.put("parentSeriesId", item.parentSeriesId) return obj.toString() } @@ -164,6 +165,8 @@ class FavoritesRepository(private val context: Context) { .takeUnless { it.isBlank() || it == "null" } val containerExtension = obj.optString("containerExtension") .takeUnless { it.isBlank() || it == "null" } + val parentSeriesId = obj.optString("parentSeriesId") + .takeUnless { it.isBlank() || it == "null" } val streamId = obj.optString("streamId") .takeUnless { it.isBlank() || it == "null" } ?: obj.optString("id") @@ -177,7 +180,8 @@ class FavoritesRepository(private val context: Context) { section = section, contentType = contentType, streamId = streamId, - containerExtension = containerExtension + containerExtension = containerExtension, + parentSeriesId = parentSeriesId ) ) }.getOrNull() diff --git a/app/src/main/java/com/example/xtreamplayer/content/VodPlaybackStateRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/VodPlaybackStateRepository.kt new file mode 100644 index 0000000..81644b6 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/content/VodPlaybackStateRepository.kt @@ -0,0 +1,210 @@ +package com.example.xtreamplayer.content + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.example.xtreamplayer.auth.AuthConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import org.json.JSONArray +import org.json.JSONObject +import kotlin.math.abs + +private val Context.vodPlaybackStateDataStore by preferencesDataStore(name = "vod_playback_state") + +class VodPlaybackStateRepository(private val context: Context) { + val entries: Flow> = + context.vodPlaybackStateDataStore.data + .map { prefs -> + val raw = prefs[Keys.VOD_PLAYBACK_STATE_ENTRIES] ?: "[]" + parseEntries(raw) + } + .flowOn(Dispatchers.Default) + + fun entriesForConfig(config: AuthConfig): Flow> { + return entries + .map { entries -> entries.filter { isEntryForConfig(it, config) } } + .distinctUntilChanged() + } + + fun entryForMediaId(config: AuthConfig, mediaId: String): Flow { + return entriesForConfig(config) + .map { entries -> entries.firstOrNull { it.mediaId == mediaId } } + .distinctUntilChanged() + } + + suspend fun updateProgress( + config: AuthConfig, + mediaId: String, + title: String, + positionMs: Long, + durationMs: Long, + subtitleFileName: String? = null, + subtitleLanguage: String? = null, + subtitleLabel: String? = null, + subtitleOffsetMs: Long = 0L + ) { + val key = contentKey(config, mediaId) + val timestampMs = System.currentTimeMillis() + context.vodPlaybackStateDataStore.edit { prefs -> + val raw = prefs[Keys.VOD_PLAYBACK_STATE_ENTRIES] ?: "[]" + val entries = parseEntries(raw).toMutableList() + val existing = entries.firstOrNull { it.key == key } + if ( + shouldSkipVodPlaybackStateWrite( + existing = existing, + title = title, + positionMs = positionMs, + durationMs = durationMs, + subtitleFileName = subtitleFileName, + subtitleLanguage = subtitleLanguage, + subtitleLabel = subtitleLabel, + subtitleOffsetMs = subtitleOffsetMs, + minSaveDeltaMs = MIN_SAVE_DELTA_MS + ) + ) { + return@edit + } + entries.removeAll { it.key == key } + entries.add( + 0, + VodPlaybackStateEntry( + key = key, + mediaId = mediaId, + title = title, + positionMs = positionMs, + durationMs = durationMs, + timestampMs = timestampMs, + subtitleFileName = subtitleFileName, + subtitleLanguage = subtitleLanguage, + subtitleLabel = subtitleLabel, + subtitleOffsetMs = subtitleOffsetMs + ) + ) + prefs[Keys.VOD_PLAYBACK_STATE_ENTRIES] = encodeEntries(entries.take(MAX_ENTRIES)) + } + } + + suspend fun removeEntry(config: AuthConfig, mediaId: String) { + val key = contentKey(config, mediaId) + context.vodPlaybackStateDataStore.edit { prefs -> + val raw = prefs[Keys.VOD_PLAYBACK_STATE_ENTRIES] ?: "[]" + val entries = parseEntries(raw).toMutableList() + entries.removeAll { it.key == key } + prefs[Keys.VOD_PLAYBACK_STATE_ENTRIES] = encodeEntries(entries) + } + } + + private fun isEntryForConfig(entry: VodPlaybackStateEntry, config: AuthConfig): Boolean { + return entry.key.startsWith("${accountKey(config)}|") + } + + private fun contentKey(config: AuthConfig, mediaId: String): String { + return "${accountKey(config)}|$mediaId" + } + + private fun accountKey(config: AuthConfig): String { + return "${config.baseUrl}|${config.username}" + } + + private fun encodeEntries(entries: List): String { + val array = JSONArray() + entries.forEach { entry -> + val obj = JSONObject() + obj.put("key", entry.key) + obj.put("mediaId", entry.mediaId) + obj.put("title", entry.title) + obj.put("positionMs", entry.positionMs) + obj.put("durationMs", entry.durationMs) + obj.put("timestampMs", entry.timestampMs) + entry.subtitleFileName?.let { obj.put("subtitleFileName", it) } + entry.subtitleLanguage?.let { obj.put("subtitleLanguage", it) } + entry.subtitleLabel?.let { obj.put("subtitleLabel", it) } + if (entry.subtitleOffsetMs != 0L) { + obj.put("subtitleOffsetMs", entry.subtitleOffsetMs) + } + array.put(obj) + } + return array.toString() + } + + private fun parseEntries(raw: String): List { + return runCatching { + val array = JSONArray(raw) + val entries = ArrayList(array.length()) + for (index in 0 until array.length()) { + val obj = array.optJSONObject(index) ?: continue + val key = obj.optString("key") + val mediaId = obj.optString("mediaId") + if (key.isBlank() || mediaId.isBlank()) continue + entries.add( + VodPlaybackStateEntry( + key = key, + mediaId = mediaId, + title = obj.optString("title"), + positionMs = obj.optLong("positionMs", 0L), + durationMs = obj.optLong("durationMs", 0L), + timestampMs = obj.optLong("timestampMs", 0L), + subtitleFileName = obj.optString("subtitleFileName") + .takeUnless { it.isBlank() || it == "null" }, + subtitleLanguage = obj.optString("subtitleLanguage") + .takeUnless { it.isBlank() || it == "null" }, + subtitleLabel = obj.optString("subtitleLabel") + .takeUnless { it.isBlank() || it == "null" }, + subtitleOffsetMs = obj.optLong("subtitleOffsetMs", 0L) + ) + ) + } + entries + }.getOrElse { emptyList() } + } + + private object Keys { + val VOD_PLAYBACK_STATE_ENTRIES = stringPreferencesKey("vod_playback_state_entries") + } + + private companion object { + const val MAX_ENTRIES = 1000 + const val MIN_SAVE_DELTA_MS = 8_000L + } +} + +internal fun shouldSkipVodPlaybackStateWrite( + existing: VodPlaybackStateEntry?, + title: String, + positionMs: Long, + durationMs: Long, + subtitleFileName: String?, + subtitleLanguage: String?, + subtitleLabel: String?, + subtitleOffsetMs: Long, + minSaveDeltaMs: Long +): Boolean { + if (existing == null) { + return false + } + return abs(existing.positionMs - positionMs) < minSaveDeltaMs && + existing.title == title && + existing.durationMs == durationMs && + existing.subtitleFileName == subtitleFileName && + existing.subtitleLanguage == subtitleLanguage && + existing.subtitleLabel == subtitleLabel && + existing.subtitleOffsetMs == subtitleOffsetMs +} + +data class VodPlaybackStateEntry( + val key: String, + val mediaId: String, + val title: String, + val positionMs: Long, + val durationMs: Long, + val timestampMs: Long, + val subtitleFileName: String? = null, + val subtitleLanguage: String? = null, + val subtitleLabel: String? = null, + val subtitleOffsetMs: Long = 0L +)