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
86 changes: 86 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/model/RecentPlayedSource.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.arflix.tv.data.model

/**
* The source a title was last really watched from, so the source picker can pin it to the top.
*
* Only a source that actually played is recorded (see RecentSourcePlayTracker): one the user
* opened and abandoned, or that failover replaced because it was slow or broken, never is.
* There is one record per title — playing another source or episode replaces it.
*
* [season]/[episode] are TMDB numbers, null for movies.
*/
data class RecentPlayedSource(
val mediaType: MediaType,
val mediaId: Int,
val season: Int? = null,
val episode: Int? = null,
val addonId: String = "",
val source: String = "",
val url: String? = null,
val infoHash: String? = null,
val fileIdx: Int? = null,
val filename: String? = null,
val playedAtMs: Long = 0L,
) {
fun appliesTo(mediaType: MediaType, mediaId: Int, season: Int?, episode: Int?): Boolean =
this.mediaType == mediaType &&
this.mediaId == mediaId &&
this.season == season &&
this.episode == episode

companion object {
fun of(
mediaType: MediaType,
mediaId: Int,
season: Int?,
episode: Int?,
stream: StreamSource,
playedAtMs: Long,
) = RecentPlayedSource(
mediaType = mediaType,
mediaId = mediaId,
season = season,
episode = episode,
addonId = stream.addonId.trim(),
source = stream.source.trim(),
url = stream.url?.trim()?.takeIf { it.isNotBlank() },
infoHash = stream.infoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() },
fileIdx = stream.fileIdx,
filename = stream.behaviorHints?.filename?.trim()?.takeIf { it.isNotBlank() },
playedAtMs = playedAtMs,
)
}
}

/**
* The stream in [streams] that is [recent]'s source, or null. Addon results are fetched fresh
* each time, so this matches on identity rather than object equality, strongest field first:
* URL, then torrent hash + file, then filename, then the addon's source text. Two addons can
* list the same file, and the user picked one of them, so the addon must match too.
*/
fun findRecentSourceMatch(streams: List<StreamSource>, recent: RecentPlayedSource?): StreamSource? {
recent ?: return null
var best: StreamSource? = null
var bestScore = 0
for (stream in streams) {
val score = recentSourceMatchScore(stream, recent)
if (score > bestScore) {
best = stream
bestScore = score
}
}
return best
}

private fun recentSourceMatchScore(stream: StreamSource, recent: RecentPlayedSource): Int {
if (stream.addonId.trim() != recent.addonId) return 0
val url = stream.url?.trim()
if (!url.isNullOrBlank() && url == recent.url) return 4
val infoHash = stream.infoHash?.trim()?.lowercase()
if (!infoHash.isNullOrBlank() && infoHash == recent.infoHash && stream.fileIdx == recent.fileIdx) return 3
val filename = stream.behaviorHints?.filename?.trim()
if (!filename.isNullOrBlank() && filename.equals(recent.filename, ignoreCase = true)) return 2
val source = stream.source.trim()
if (source.isNotBlank() && source == recent.source) return 1
return 0
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.arflix.tv.data.repository

import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.edit
import com.arflix.tv.data.model.MediaType
import com.arflix.tv.data.model.RecentPlayedSource
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

/**
* Per-profile store of each title's [RecentPlayedSource]: one record per title, the most
* recently played [MAX_TITLES] titles kept.
*/
@Singleton
class RecentPlayedSourceRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val profileManager: ProfileManager,
) {
private val gson = Gson()
private val recordsType = object : TypeToken<Map<String, RecentPlayedSource>>() {}.type
// Saves outlive the player screen: leaving right after the threshold must not drop the write.
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

/** The active profile's record for this title, whichever episode it was for. */
suspend fun get(mediaType: MediaType, mediaId: Int): RecentPlayedSource? = withContext(Dispatchers.IO) {
try {
val raw = context.streamDataStore.data.first()[profileManager.profileStringKey(RECORDS_KEY)].orEmpty()
decode(raw)[titleKey(mediaType, mediaId)]
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
Log.w(TAG, "read failed: ${e.message}")
null
}
}

/** Replaces the title's record. [profileId] is the profile that watched it. */
fun save(profileId: String, record: RecentPlayedSource) {
scope.launch {
try {
val key = profileManager.profileStringKeyFor(profileId, RECORDS_KEY)
context.streamDataStore.edit { prefs ->
val records = decode(prefs[key].orEmpty()) +
(titleKey(record.mediaType, record.mediaId) to record)
val kept = records.entries
.sortedByDescending { it.value.playedAtMs }
.take(MAX_TITLES)
.associate { it.key to it.value }
prefs[key] = gson.toJson(kept)
}
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
Log.w(TAG, "save failed: ${e.message}")
}
}
}

private fun decode(raw: String): Map<String, RecentPlayedSource> {
if (raw.isBlank()) return emptyMap()
return runCatching {
gson.fromJson<Map<String, RecentPlayedSource>>(raw, recordsType)
}.getOrNull().orEmpty()
}

private fun titleKey(mediaType: MediaType, mediaId: Int) = "${mediaType.name}:$mediaId"

private companion object {
const val TAG = "RecentPlayedSource"
const val RECORDS_KEY = "recent_played_sources_v1"
const val MAX_TITLES = 200
}
}
59 changes: 57 additions & 2 deletions app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.HighQuality
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.PlayArrow
Expand Down Expand Up @@ -104,6 +105,8 @@ import androidx.tv.foundation.lazy.list.rememberTvLazyListState
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
import com.arflix.tv.data.model.IptvVodSourceIds
import com.arflix.tv.data.model.RecentPlayedSource
import com.arflix.tv.data.model.findRecentSourceMatch
import com.arflix.tv.data.model.isDirectStreamUrl
import com.arflix.tv.data.model.StreamSource
import com.arflix.tv.ui.focus.arvioDpadFocusGroup
Expand Down Expand Up @@ -231,6 +234,7 @@ fun StreamSelector(
isVisible: Boolean,
streams: List<StreamSource>,
selectedStream: StreamSource?,
recentSource: RecentPlayedSource? = null,
isLoading: Boolean = false,
title: String = "",
subtitle: String = "",
Expand Down Expand Up @@ -397,6 +401,12 @@ fun StreamSelector(
sourceAddonTabKeys(addonTabs)
}

// The source this title was last really watched from leads the list whatever the sort
// says, so picking up where you left off is the first row.
val recentStream = remember(streams, recentSource) {
findRecentSourceMatch(streams, recentSource)
}

val unknownSourceLabel = stringResource(R.string.stream_unknown_source)
val presentations = remember(streams, unknownSourceLabel) {
streams.map { presentSource(it, unknownSourceLabel) }
Expand All @@ -421,12 +431,12 @@ fun StreamSelector(
// aggregator addons (AIOStreams): they sort server-side per the user's own web config, so
// their streams keep arrival order (same exemption as PlayerViewModel.keepsOwnStreamOrder;
// re-sorting here silently overrode the user's configured order in the source menu).
val orderedPresentations = remember(presentations, addonOrder) {
val orderedPresentations = remember(presentations, addonOrder, recentStream) {
val qualityOrder = compareByDescending<IndexedValue<SourcePresentation>> { it.value.resolutionScore }
.thenByDescending { it.value.releaseScore }
.thenByDescending { it.value.sizeBytes }
.thenBy { it.value.title.lowercase() }
presentations.withIndex()
val sorted = presentations.withIndex()
.sortedWith(
compareBy<IndexedValue<SourcePresentation>> {
addonOrder[sourceTabId(it.value.stream)] ?: Int.MAX_VALUE
Expand All @@ -439,6 +449,12 @@ fun StreamSelector(
}
)
.map { it.value }
val recentIndex = sorted.indexOfFirst { it.stream === recentStream }
if (recentIndex > 0) {
listOf(sorted[recentIndex]) + sorted.filterIndexed { index, _ -> index != recentIndex }
} else {
sorted
}
}

// Filter streams by selected tab
Expand Down Expand Up @@ -659,6 +675,7 @@ fun StreamSelector(
streams = streams,
flatPresentations = flatPresentations,
selectedStream = selectedStream,
recentStream = recentStream,
sourceFilters = sourceFilters.map { it.label },
selectedFilterIndex = selectedFilterIndex,
focusedFilterIndex = focusedFilterIndex,
Expand Down Expand Up @@ -869,6 +886,7 @@ fun StreamSelector(
MobileStreamCard(
presentation = presentation,
isSelected = isSelectedSource(presentation.stream, selectedStream),
isRecent = presentation.stream === recentStream,
onClick = { onSelect(presentation.stream) }
)
}
Expand All @@ -889,6 +907,7 @@ private fun OledSourceSelectorTv(
streams: List<StreamSource>,
flatPresentations: List<SourcePresentation>,
selectedStream: StreamSource?,
recentStream: StreamSource?,
sourceFilters: List<String>,
selectedFilterIndex: Int,
focusedFilterIndex: Int,
Expand Down Expand Up @@ -1055,6 +1074,7 @@ private fun OledSourceSelectorTv(
presentation = presentation,
isFocused = streamsFocused && index == focusedIndex,
isSelected = isSelectedSource(presentation.stream, selectedStream),
isRecent = presentation.stream === recentStream,
onClick = { onSelect(presentation.stream) }
)
}
Expand Down Expand Up @@ -2114,6 +2134,7 @@ private fun OledSourceRow(
presentation: SourcePresentation,
isFocused: Boolean,
isSelected: Boolean,
isRecent: Boolean,
onClick: () -> Unit
) {
Column(
Expand Down Expand Up @@ -2158,6 +2179,10 @@ private fun OledSourceRow(
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(0.58f)) {
if (isRecent) {
RecentlyPlayedChip()
Spacer(modifier = Modifier.height(5.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
// Full filename, wrapped — an ellipsized release name hides exactly the
// parts that differentiate sources (edition, audio, group).
Expand Down Expand Up @@ -2205,6 +2230,31 @@ private fun OledSourceRow(
}
}

/** Inverted (white) so it reads at a glance against the monochrome rows. */
@Composable
private fun RecentlyPlayedChip() {
Row(
modifier = Modifier
.background(Color.White, RoundedCornerShape(999.dp))
.padding(horizontal = 8.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.History,
contentDescription = null,
tint = Color.Black,
modifier = Modifier.size(12.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.stream_recently_played),
style = ArflixTypography.caption.copy(fontSize = 10.sp, fontWeight = FontWeight.Bold),
color = Color.Black,
maxLines = 1
)
}
}

@Composable
private fun SourceBadgeTray(
presentation: SourcePresentation,
Expand Down Expand Up @@ -2396,6 +2446,7 @@ private fun OledTextBadge(
private fun MobileStreamCard(
presentation: SourcePresentation,
isSelected: Boolean,
isRecent: Boolean,
onClick: () -> Unit
) {
Row(
Expand All @@ -2410,6 +2461,10 @@ private fun MobileStreamCard(
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
if (isRecent) {
RecentlyPlayedChip()
Spacer(modifier = Modifier.height(6.dp))
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,7 @@ fun DetailsScreen(
isVisible = showStreamSelector,
streams = selectorStreams,
selectedStream = null,
recentSource = uiState.recentSource,
isLoading = uiState.isLoadingStreams,
hasStreamingAddons = uiState.hasStreamingAddons,
addonOrderedIds = uiState.addonOrderedIds,
Expand Down
Loading
Loading