Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
609 changes: 452 additions & 157 deletions app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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?,
Expand All @@ -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?,
Expand Down
32 changes: 20 additions & 12 deletions app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1006,6 +1006,7 @@ class XtreamApi(

private fun parseSeriesEpisodes(
reader: JsonReader,
seriesId: String,
offset: Int,
limit: Int
): ContentPage {
Expand Down Expand Up @@ -1049,7 +1050,7 @@ class XtreamApi(
}
break
}
val item = parseEpisodeItem(reader, seasonKey)
val item = parseEpisodeItem(reader, seriesId, seasonKey)
if (item != null) {
items.add(item)
}
Expand Down Expand Up @@ -1374,6 +1375,7 @@ class XtreamApi(

private fun parseSeriesSeasonPage(
reader: JsonReader,
seriesId: String,
seasonLabel: String,
offset: Int,
limit: Int
Expand Down Expand Up @@ -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)
Expand All @@ -1432,6 +1434,7 @@ class XtreamApi(

private fun parseSeriesSeasonAll(
reader: JsonReader,
seriesId: String,
seasonLabel: String
): List<ContentItem> {
if (reader.peek() != JsonToken.BEGIN_OBJECT) {
Expand Down Expand Up @@ -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()
}
Expand All @@ -1473,7 +1476,7 @@ class XtreamApi(
return items
}

private fun parseSeriesEpisodesAll(reader: JsonReader): List<ContentItem> {
private fun parseSeriesEpisodesAll(reader: JsonReader, seriesId: String): List<ContentItem> {
if (reader.peek() != JsonToken.BEGIN_OBJECT) {
reader.skipValue()
return emptyList()
Expand All @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -1571,7 +1578,8 @@ class XtreamApi(
duration = episodeInfo?.duration,
rating = episodeInfo?.rating,
seasonLabel = seasonLabel,
episodeNumber = episodeNum
episodeNumber = episodeNum,
parentSeriesId = seriesId
)
return EpisodeEntry(
item = item,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -127,7 +141,7 @@ class ContinueWatchingRepository(private val context: Context) {

fun continueWatchingEntriesForConfig(config: AuthConfig): Flow<List<ContinueWatchingEntry>> {
return continueWatchingEntries
.map { entries -> entries.filter { isEntryForConfig(it, config) } }
.map { entries -> canonicalizeEntries(entries.filter { isEntryForConfig(it, config) }) }
.distinctUntilChanged()
}

Expand Down Expand Up @@ -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)
Expand All @@ -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) }
Expand Down Expand Up @@ -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")
Expand All @@ -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" }
Expand All @@ -278,7 +298,8 @@ class ContinueWatchingRepository(private val context: Context) {
section = parentSection,
contentType = parentContentType,
streamId = parentStreamId,
containerExtension = parentContainerExtension
containerExtension = parentContainerExtension,
parentSeriesId = parentParentSeriesId
)
}

Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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>
): 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<ContinueWatchingEntry>): List<ContinueWatchingEntry> {
val grouped = LinkedHashMap<String, MutableList<ContinueWatchingEntry>>()
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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")
Expand All @@ -177,7 +180,8 @@ class FavoritesRepository(private val context: Context) {
section = section,
contentType = contentType,
streamId = streamId,
containerExtension = containerExtension
containerExtension = containerExtension,
parentSeriesId = parentSeriesId
)
)
}.getOrNull()
Expand Down
Loading
Loading