From 1f7ab0b0c5c496e62afc992f661f6d382aa143b8 Mon Sep 17 00:00:00 2001 From: silentbil Date: Wed, 7 Oct 2026 10:29:37 +0300 Subject: [PATCH 1/3] feat(sources): pin the recently played source to the top of the source list When a title is reopened, the source it was last really watched from leads the source picker with a "Recently played" chip, ahead of the normal sort. - One record per title per profile (last 200 titles kept); playing another source or episode replaces it. - A source is recorded only after 10 s of real playback on it. Pausing, buffering and seeks do not count, and a new selection (manual or failover) starts over, so a source abandoned for being slow or broken is never kept. - Later source lists are matched by URL, then torrent hash + file index, then filename, then source text, always within the same addon. The addon's own entry is recorded, not the copy resolved for playback. - Shown in the Details source picker (TV and mobile) and the player's source menu. Series match the exact episode only. Live streams are skipped. --- .../tv/data/model/RecentPlayedSource.kt | 86 ++++++++++++++ .../RecentPlayedSourceRepository.kt | 81 +++++++++++++ .../arflix/tv/ui/components/StreamSelector.kt | 59 +++++++++- .../tv/ui/screens/details/DetailsScreen.kt | 1 + .../tv/ui/screens/details/DetailsViewModel.kt | 19 +++- .../tv/ui/screens/player/PlayerScreen.kt | 1 + .../tv/ui/screens/player/PlayerViewModel.kt | 57 +++++++++- .../screens/player/RecentSourcePlayTracker.kt | 52 +++++++++ app/src/main/res/values-iw/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../tv/data/model/RecentPlayedSourceTest.kt | 106 ++++++++++++++++++ .../details/DetailsStreamCompletionTest.kt | 3 +- .../details/DetailsWatchlistToggleTest.kt | 3 +- .../player/ForcedSubtitleSelectionTest.kt | 3 +- .../player/RecentSourcePlayTrackerTest.kt | 104 +++++++++++++++++ 15 files changed, 569 insertions(+), 8 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/data/model/RecentPlayedSource.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/RecentPlayedSourceRepository.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/player/RecentSourcePlayTracker.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/data/model/RecentPlayedSourceTest.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/ui/screens/player/RecentSourcePlayTrackerTest.kt diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/RecentPlayedSource.kt b/app/src/main/kotlin/com/arflix/tv/data/model/RecentPlayedSource.kt new file mode 100644 index 000000000..c4817e34d --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/model/RecentPlayedSource.kt @@ -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, 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 +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/RecentPlayedSourceRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/RecentPlayedSourceRepository.kt new file mode 100644 index 000000000..793605338 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/RecentPlayedSourceRepository.kt @@ -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>() {}.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 { + if (raw.isBlank()) return emptyMap() + return runCatching { + gson.fromJson>(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 + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt index 3d2f5b60d..67e68c7e0 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt @@ -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 @@ -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 @@ -231,6 +234,7 @@ fun StreamSelector( isVisible: Boolean, streams: List, selectedStream: StreamSource?, + recentSource: RecentPlayedSource? = null, isLoading: Boolean = false, title: String = "", subtitle: String = "", @@ -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) } @@ -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> { it.value.resolutionScore } .thenByDescending { it.value.releaseScore } .thenByDescending { it.value.sizeBytes } .thenBy { it.value.title.lowercase() } - presentations.withIndex() + val sorted = presentations.withIndex() .sortedWith( compareBy> { addonOrder[sourceTabId(it.value.stream)] ?: Int.MAX_VALUE @@ -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 @@ -659,6 +675,7 @@ fun StreamSelector( streams = streams, flatPresentations = flatPresentations, selectedStream = selectedStream, + recentStream = recentStream, sourceFilters = sourceFilters.map { it.label }, selectedFilterIndex = selectedFilterIndex, focusedFilterIndex = focusedFilterIndex, @@ -869,6 +886,7 @@ fun StreamSelector( MobileStreamCard( presentation = presentation, isSelected = isSelectedSource(presentation.stream, selectedStream), + isRecent = presentation.stream === recentStream, onClick = { onSelect(presentation.stream) } ) } @@ -889,6 +907,7 @@ private fun OledSourceSelectorTv( streams: List, flatPresentations: List, selectedStream: StreamSource?, + recentStream: StreamSource?, sourceFilters: List, selectedFilterIndex: Int, focusedFilterIndex: Int, @@ -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) } ) } @@ -2114,6 +2134,7 @@ private fun OledSourceRow( presentation: SourcePresentation, isFocused: Boolean, isSelected: Boolean, + isRecent: Boolean, onClick: () -> Unit ) { Column( @@ -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). @@ -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, @@ -2396,6 +2446,7 @@ private fun OledTextBadge( private fun MobileStreamCard( presentation: SourcePresentation, isSelected: Boolean, + isRecent: Boolean, onClick: () -> Unit ) { Row( @@ -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, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index 352ba9e59..f6dbdbde7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -992,6 +992,7 @@ fun DetailsScreen( isVisible = showStreamSelector, streams = selectorStreams, selectedStream = null, + recentSource = uiState.recentSource, isLoading = uiState.isLoadingStreams, hasStreamingAddons = uiState.hasStreamingAddons, addonOrderedIds = uiState.addonOrderedIds, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index a58fb0162..6c679de5a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -15,6 +15,7 @@ import com.arflix.tv.data.model.Episode import com.arflix.tv.data.model.EpisodeIdentity import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.model.RecentPlayedSource import com.arflix.tv.data.model.PersonDetails import com.arflix.tv.data.model.Review import com.arflix.tv.data.model.SportsAddonCapabilities @@ -31,6 +32,7 @@ import com.arflix.tv.data.repository.MediaRepository import com.arflix.tv.data.repository.MdbExternalRating import com.arflix.tv.data.repository.MdbListRepository import com.arflix.tv.data.repository.ProfileManager +import com.arflix.tv.data.repository.RecentPlayedSourceRepository import com.arflix.tv.data.model.StreamIntegrationType import com.arflix.tv.data.repository.StreamIntegrationRepository import com.arflix.tv.data.repository.StreamRepository @@ -97,6 +99,8 @@ data class DetailsUiState( // Streams val streams: List = emptyList(), val streamsEpisodeIdentity: EpisodeIdentity? = null, + // The source the loaded streams' title/episode was last really watched from. + val recentSource: RecentPlayedSource? = null, val subtitles: List = emptyList(), val isLoadingStreams: Boolean = false, val streamSearchStartTime: Long = 0L, @@ -251,7 +255,8 @@ class DetailsViewModel @Inject constructor( private val watchlistRepository: WatchlistRepository, private val cloudSyncRepository: CloudSyncRepository, private val launcherContinueWatchingRepository: LauncherContinueWatchingRepository, - private val streamIntegrationRepository: StreamIntegrationRepository + private val streamIntegrationRepository: StreamIntegrationRepository, + private val recentPlayedSourceRepository: RecentPlayedSourceRepository ) : ViewModel() { companion object { @@ -2051,6 +2056,7 @@ class DetailsViewModel @Inject constructor( totalAddons = 0, streams = emptyList(), streamsEpisodeIdentity = identity, + recentSource = null, subtitles = emptyList(), streamSearchStartTime = System.currentTimeMillis(), pluginScrapersLoading = false, @@ -2060,6 +2066,17 @@ class DetailsViewModel @Inject constructor( val requestMediaType = currentMediaType val requestMediaId = currentMediaId + viewModelScope.launch { + val recent = recentPlayedSourceRepository.get(requestMediaType, requestMediaId) + ?.takeIf { + it.appliesTo(requestMediaType, requestMediaId, identity?.tmdbSeason, identity?.tmdbEpisode) + } + ?: return@launch + if (requestId == loadStreamsRequestId) { + _uiState.value = _uiState.value.copy(recentSource = recent) + } + } + // Register the job before it can synchronously finish or launch providers. loadStreamsJob = viewModelScope.launch(start = CoroutineStart.LAZY) { fun isCurrentRequest(): Boolean { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index 5408a2544..3f873aff7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -4745,6 +4745,7 @@ fun PlayerScreen( isVisible = showSourceMenu, streams = uiState.streams, selectedStream = uiState.selectedStream, + recentSource = uiState.recentSource, isLoading = uiState.isLoadingStreams, hasStreamingAddons = !uiState.isSetupError, addonOrderedIds = uiState.addonOrderedIds, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index b551e00c8..2df818320 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -15,6 +15,8 @@ import com.arflix.tv.data.model.Addon import com.arflix.tv.data.model.AddonType import com.arflix.tv.data.model.AnimeStructuringStyle import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.model.RecentPlayedSource +import com.arflix.tv.data.model.findRecentSourceMatch import com.arflix.tv.data.model.EpisodeIdentity import com.arflix.tv.data.model.SportsAddonCapabilities import com.arflix.tv.data.model.IptvVodSourceIds @@ -25,6 +27,7 @@ import com.arflix.tv.data.repository.MediaRepository import com.arflix.tv.data.repository.HomeServerRepository import com.arflix.tv.data.repository.PlaybackTelemetryRepository import com.arflix.tv.data.repository.ProfileManager +import com.arflix.tv.data.repository.RecentPlayedSourceRepository import com.arflix.tv.data.repository.SkipInterval import com.arflix.tv.data.repository.SkipIntroRepository import com.arflix.tv.data.repository.StreamRepository @@ -160,6 +163,8 @@ data class PlayerUiState( val selectedStream: StreamSource? = null, val selectedStreamUrl: String? = null, val streamSelectionNonce: Int = 0, + // This title's recently played source, pinned at the top of the source menu. + val recentSource: RecentPlayedSource? = null, val selectedSubtitle: Subtitle? = null, val subtitleSelectionNonce: Int = 0, // "Preload Subtitles" mode: preferred-language addon subs downloaded to local files before @@ -297,7 +302,8 @@ class PlayerViewModel @Inject constructor( private val skipIntroRepository: SkipIntroRepository, private val playbackTelemetryRepository: PlaybackTelemetryRepository, private val pluginManager: PluginManager, - private val streamIntegrationRepository: StreamIntegrationRepository + private val streamIntegrationRepository: StreamIntegrationRepository, + private val recentPlayedSourceRepository: RecentPlayedSourceRepository ) : ViewModel() { private val _uiState = MutableStateFlow(PlayerUiState()) @@ -350,6 +356,7 @@ class PlayerViewModel @Inject constructor( // A late-arriving embedded preferred-language track overrides auto selections but never this. private var userPickedSubtitle: Boolean = false private var playbackSessionStartTime: Long = 0L + private val recentSourcePlayTracker = RecentSourcePlayTracker() private fun isCurrentAnime(): Boolean = currentMediaType == MediaType.TV && @@ -772,8 +779,10 @@ class PlayerViewModel @Inject constructor( streamLoadPhase = if (providedStreamUrl.isNullOrBlank()) null else PlayerMessage.Res(R.string.player_phase_preparing_stream), sourceSearchActive = false, error = null, - isSetupError = false + isSetupError = false, + recentSource = null ) + loadRecentSource(mediaType, mediaId, seasonNumber, episodeNumber) lastTopPrewarmKey = "" skipIntervalsJob?.cancel() currentImdbId = providedImdbId @@ -7672,6 +7681,49 @@ class PlayerViewModel @Inject constructor( ) } + private fun loadRecentSource(mediaType: MediaType, mediaId: Int, season: Int?, episode: Int?) { + recentSourcePlayTracker.reset() + viewModelScope.launch { + val recent = recentPlayedSourceRepository.get(mediaType, mediaId) + ?.takeIf { it.appliesTo(mediaType, mediaId, season, episode) } + ?: return@launch + val stillCurrent = currentMediaType == mediaType && currentMediaId == mediaId && + currentSeason == season && currentEpisode == episode + // A source that crossed the threshold in this session is newer than the stored one. + if (stillCurrent && _uiState.value.recentSource == null) { + _uiState.value = _uiState.value.copy(recentSource = recent) + } + } + } + + /** Records the selected source as this title's recently played one once it has really played. */ + private fun noteRecentSourcePlayback(positionMs: Long, isPlaying: Boolean) { + if (currentIsLiveStreamPlayback || currentMediaId <= 0) return + val state = _uiState.value + val stream = state.selectedStream ?: return + val reached = recentSourcePlayTracker.onProgress( + selectionId = state.streamSelectionNonce.toLong(), + positionMs = positionMs, + isPlaying = isPlaying, + nowMs = android.os.SystemClock.elapsedRealtime() + ) + if (!reached) return + fun recordOf(source: StreamSource) = RecentPlayedSource.of( + mediaType = currentMediaType, + mediaId = currentMediaId, + season = currentSeason, + episode = currentEpisode, + stream = source, + playedAtMs = System.currentTimeMillis() + ) + // Record the addon's own entry, not the copy resolved for playback (Stalker and HubCloud + // links are exchanged for one-off URLs): later source lists are matched against it. + val listed = findRecentSourceMatch(state.streams, recordOf(stream)) ?: stream + val record = recordOf(listed) + _uiState.value = _uiState.value.copy(recentSource = record) + recentPlayedSourceRepository.save(profileManager.getProfileIdSync(), record) + } + fun saveProgress( position: Long, duration: Long, @@ -7680,6 +7732,7 @@ class PlayerViewModel @Inject constructor( playbackState: Int ): Job? { if (duration <= 0) return null + noteRecentSourcePlayback(position, isPlaying) // A completion transaction must not be cancelled/restarted by the ended polling tick // or the player's disposal save. It can finish after the UI's short navigation wait. diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/RecentSourcePlayTracker.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/RecentSourcePlayTracker.kt new file mode 100644 index 000000000..2f0b7f05e --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/RecentSourcePlayTracker.kt @@ -0,0 +1,52 @@ +package com.arflix.tv.ui.screens.player + +/** + * Decides when the selected source has played long enough to become the title's recently + * played source. Opening a source is not enough: one that is abandoned or failed over inside + * the threshold, because it was slow or broken, must never be recorded. + * + * Time counts only while playing, and each step counts the smaller of the media time and the + * wall time that passed, so a seek (media time jumps) or a stalled tick (wall time jumps) + * cannot inflate it. Resuming deep into a title counts from zero. A new selection starts over. + */ +internal class RecentSourcePlayTracker( + private val thresholdMs: Long = RECENT_SOURCE_MIN_PLAY_MS, +) { + private var selectionId: Long? = null + private var lastPositionMs = -1L + private var lastTickMs = -1L + private var playedMs = 0L + private var reached = false + + fun reset() { + selectionId = null + lastPositionMs = -1L + lastTickMs = -1L + playedMs = 0L + reached = false + } + + /** True once per selection: on the tick its played time reaches the threshold. */ + fun onProgress(selectionId: Long, positionMs: Long, isPlaying: Boolean, nowMs: Long): Boolean { + if (this.selectionId != selectionId) { + reset() + this.selectionId = selectionId + } + val previousPositionMs = lastPositionMs + val previousTickMs = lastTickMs + lastPositionMs = positionMs + lastTickMs = nowMs + if (reached || !isPlaying || previousPositionMs < 0L) return false + + val step = minOf(positionMs - previousPositionMs, nowMs - previousTickMs) + if (step <= 0L) return false + playedMs += step + if (playedMs < thresholdMs) return false + reached = true + return true + } + + companion object { + const val RECENT_SOURCE_MIN_PLAY_MS = 10_000L + } +} diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index dca02db3e..45a8ab161 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -62,6 +62,7 @@ רשימת הצפייה ריקה הוסף סרטים וסדרות לצפייה מאוחר יותר מקורות זמינים + נוגן לאחרונה מסתיים ב שפת תוכן diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8022b4237..db7b97a79 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1349,6 +1349,7 @@ The latest ARVIO update has been downloaded and is ready to install. The Android package installer should appear. If it does not, you can try pressing Install again. All sources + Recently played Select Source No Streaming Addons No sources found diff --git a/app/src/test/kotlin/com/arflix/tv/data/model/RecentPlayedSourceTest.kt b/app/src/test/kotlin/com/arflix/tv/data/model/RecentPlayedSourceTest.kt new file mode 100644 index 000000000..bf04e69f0 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/model/RecentPlayedSourceTest.kt @@ -0,0 +1,106 @@ +package com.arflix.tv.data.model + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class RecentPlayedSourceTest { + + @Test + fun matchesTheSameUrl() { + val played = stream(url = "https://addon/play/abc") + val streams = listOf(stream(url = "https://addon/play/other"), stream(url = "https://addon/play/abc")) + + assertThat(findRecentSourceMatch(streams, recordOf(played))).isSameInstanceAs(streams[1]) + } + + @Test + fun matchesTheSameTorrentFileWhenTheUrlChanged() { + val played = stream(url = "https://addon/play/old", infoHash = "ABCDEF", fileIdx = 2) + val streams = listOf( + stream(url = "https://addon/play/x", infoHash = "abcdef", fileIdx = 1), + stream(url = "https://addon/play/y", infoHash = "abcdef", fileIdx = 2), + ) + + assertThat(findRecentSourceMatch(streams, recordOf(played))).isSameInstanceAs(streams[1]) + } + + @Test + fun matchesTheSameFilenameIgnoringCase() { + val played = stream(url = "https://addon/play/old", filename = "Movie.2024.2160p.REMUX.mkv") + val streams = listOf( + stream(url = "https://addon/play/new", filename = "movie.2024.2160p.remux.mkv"), + ) + + assertThat(findRecentSourceMatch(streams, recordOf(played))).isSameInstanceAs(streams[0]) + } + + @Test + fun fallsBackToTheSourceText() { + val played = stream(source = "Movie 1080p WEB-DL", url = "https://addon/play/old") + val streams = listOf( + stream(source = "Movie 720p", url = "https://addon/play/a"), + stream(source = "Movie 1080p WEB-DL", url = "https://addon/play/b"), + ) + + assertThat(findRecentSourceMatch(streams, recordOf(played))).isSameInstanceAs(streams[1]) + } + + @Test + fun prefersTheStrongestMatch() { + val played = stream(source = "Shared title", url = "https://addon/play/exact") + val streams = listOf( + stream(source = "Shared title", url = "https://addon/play/other"), + stream(source = "Shared title", url = "https://addon/play/exact"), + ) + + assertThat(findRecentSourceMatch(streams, recordOf(played))).isSameInstanceAs(streams[1]) + } + + @Test + fun theSameFileFromAnotherAddonIsNotTheRecentSource() { + val played = stream(addonId = "torrentio", url = "https://same/url", filename = "a.mkv") + val streams = listOf(stream(addonId = "comet", url = "https://same/url", filename = "a.mkv")) + + assertThat(findRecentSourceMatch(streams, recordOf(played))).isNull() + } + + @Test + fun noRecordOrNoMatchFindsNothing() { + val streams = listOf(stream(source = "Something else", url = "https://addon/play/a")) + + assertThat(findRecentSourceMatch(streams, null)).isNull() + assertThat(findRecentSourceMatch(streams, recordOf(stream(source = "Gone", url = "https://gone")))).isNull() + } + + @Test + fun appliesOnlyToTheSameTitleAndEpisode() { + val record = RecentPlayedSource.of(MediaType.TV, 42, 1, 5, stream(), playedAtMs = 1L) + + assertThat(record.appliesTo(MediaType.TV, 42, 1, 5)).isTrue() + assertThat(record.appliesTo(MediaType.TV, 42, 1, 6)).isFalse() + assertThat(record.appliesTo(MediaType.TV, 43, 1, 5)).isFalse() + assertThat(record.appliesTo(MediaType.MOVIE, 42, null, null)).isFalse() + } + + private fun recordOf(played: StreamSource) = + RecentPlayedSource.of(MediaType.MOVIE, 7, null, null, played, playedAtMs = 1L) + + private fun stream( + addonId: String = "torrentio", + source: String = "Movie", + url: String? = null, + infoHash: String? = null, + fileIdx: Int? = null, + filename: String? = null, + ) = StreamSource( + source = source, + addonName = "Torrentio", + addonId = addonId, + quality = "1080p", + size = "4 GB", + url = url, + infoHash = infoHash, + fileIdx = fileIdx, + behaviorHints = filename?.let { StreamBehaviorHints(filename = it) }, + ) +} diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/details/DetailsStreamCompletionTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/details/DetailsStreamCompletionTest.kt index 9e6d4fba6..b48061ccb 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/details/DetailsStreamCompletionTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/details/DetailsStreamCompletionTest.kt @@ -83,7 +83,8 @@ class DetailsStreamCompletionTest { watchlistRepository = mockk(relaxed = true), cloudSyncRepository = mockk(relaxed = true), launcherContinueWatchingRepository = mockk(relaxed = true), - streamIntegrationRepository = integrations + streamIntegrationRepository = integrations, + recentPlayedSourceRepository = mockk(relaxed = true) ) val store = ViewModelStore().apply { put("details", model) } try { diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/details/DetailsWatchlistToggleTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/details/DetailsWatchlistToggleTest.kt index 6a665e00c..60d6e729e 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/details/DetailsWatchlistToggleTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/details/DetailsWatchlistToggleTest.kt @@ -110,7 +110,8 @@ class DetailsWatchlistToggleTest { watchlistRepository = watchlist, cloudSyncRepository = cloud, launcherContinueWatchingRepository = mockk(relaxed = true), - streamIntegrationRepository = integrations + streamIntegrationRepository = integrations, + recentPlayedSourceRepository = mockk(relaxed = true) ) store.put("details", model) model.loadDetails(MediaType.MOVIE, movie.id) diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/player/ForcedSubtitleSelectionTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/player/ForcedSubtitleSelectionTest.kt index be2310349..dae19a313 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/player/ForcedSubtitleSelectionTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/player/ForcedSubtitleSelectionTest.kt @@ -49,7 +49,8 @@ class ForcedSubtitleSelectionTest { mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), - streamIntegrationRepository = mockk(relaxed = true) + streamIntegrationRepository = mockk(relaxed = true), + recentPlayedSourceRepository = mockk(relaxed = true) ) store.put("player", model) state().value = PlayerUiState(useForcedSubtitles = true) diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/player/RecentSourcePlayTrackerTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/player/RecentSourcePlayTrackerTest.kt new file mode 100644 index 000000000..62113675e --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/player/RecentSourcePlayTrackerTest.kt @@ -0,0 +1,104 @@ +package com.arflix.tv.ui.screens.player + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class RecentSourcePlayTrackerTest { + + @Test + fun reachesTheThresholdAfterTenSecondsOfPlaying() { + val tracker = RecentSourcePlayTracker() + + // Progress is reported about every 2 s here; starting deep into the title counts from zero. + val reached = playSteadily(tracker, startPositionMs = 2_400_000L, seconds = 10) + + assertThat(reached).containsExactly(10_000L) + } + + @Test + fun aSourceAbandonedBeforeTenSecondsIsNeverReported() { + val tracker = RecentSourcePlayTracker() + + val reached = playSteadily(tracker, startPositionMs = 0L, seconds = 8) + + assertThat(reached).isEmpty() + } + + @Test + fun anotherSelectionStartsOver() { + val tracker = RecentSourcePlayTracker() + var now = 0L + var position = 0L + repeat(4) { + tracker.onProgress(selectionId = 1, positionMs = position, isPlaying = true, nowMs = now) + now += 2_000L + position += 2_000L + } + + // Failover or the user picks another source: its 10 s count from its own start. + val reachedAt = mutableListOf() + repeat(8) { tick -> + if (tracker.onProgress(selectionId = 2, positionMs = position, isPlaying = true, nowMs = now)) { + reachedAt += tick + } + now += 2_000L + position += 2_000L + } + + assertThat(reachedAt).containsExactly(5) + } + + @Test + fun pausedTimeDoesNotCount() { + val tracker = RecentSourcePlayTracker() + tracker.onProgress(1, positionMs = 0L, isPlaying = true, nowMs = 0L) + tracker.onProgress(1, positionMs = 6_000L, isPlaying = true, nowMs = 6_000L) + // Paused for an hour, then playing again. + tracker.onProgress(1, positionMs = 6_000L, isPlaying = false, nowMs = 7_000L) + tracker.onProgress(1, positionMs = 6_000L, isPlaying = false, nowMs = 3_600_000L) + + assertThat(tracker.onProgress(1, positionMs = 8_000L, isPlaying = true, nowMs = 3_602_000L)).isFalse() + assertThat(tracker.onProgress(1, positionMs = 10_000L, isPlaying = true, nowMs = 3_604_000L)).isTrue() + } + + @Test + fun seekingForwardDoesNotCountAsPlaying() { + val tracker = RecentSourcePlayTracker() + tracker.onProgress(1, positionMs = 0L, isPlaying = true, nowMs = 0L) + + // A 20-minute skip two seconds later is two seconds played, not twenty minutes. + assertThat(tracker.onProgress(1, positionMs = 1_200_000L, isPlaying = true, nowMs = 2_000L)).isFalse() + assertThat(tracker.onProgress(1, positionMs = 1_206_000L, isPlaying = true, nowMs = 8_000L)).isFalse() + assertThat(tracker.onProgress(1, positionMs = 1_208_000L, isPlaying = true, nowMs = 10_000L)).isTrue() + } + + @Test + fun seekingBackDoesNotCount() { + val tracker = RecentSourcePlayTracker() + tracker.onProgress(1, positionMs = 600_000L, isPlaying = true, nowMs = 0L) + + assertThat(tracker.onProgress(1, positionMs = 10_000L, isPlaying = true, nowMs = 40_000L)).isFalse() + } + + @Test + fun reportsOncePerSelection() { + val tracker = RecentSourcePlayTracker() + + val reached = playSteadily(tracker, startPositionMs = 0L, seconds = 60) + + assertThat(reached).hasSize(1) + } + + /** Plays [seconds] at 1x with a progress report every 2 s; returns the played times that reported. */ + private fun playSteadily(tracker: RecentSourcePlayTracker, startPositionMs: Long, seconds: Int): List { + val reached = mutableListOf() + var elapsed = 0L + while (elapsed <= seconds * 1_000L) { + if (tracker.onProgress(1, startPositionMs + elapsed, isPlaying = true, nowMs = elapsed)) { + reached += elapsed + } + elapsed += 2_000L + } + return reached + } +} From 04ecf688020663343b2f0635ea0a5caf28df9891 Mon Sep 17 00:00:00 2001 From: silentbil Date: Wed, 7 Oct 2026 16:09:14 +0300 Subject: [PATCH 2/3] feat(autoplay): prefer the recently played source when autoplaying Autoplay users never see the source list, so the pin alone did nothing for them. When the title has a recently played source, autoplay now picks it over its own ranking. - Its addon may answer after autoplay's quick picks (a cached source, the 1.75 s window) would fire, so those hold for up to 6 s while it is missing. The hold ends as soon as it arrives, when the search finishes, or when it arrives but the autoplay quality settings filter it out. - Titles without a recent source autoplay exactly as before. - Failover is unchanged: it walks the source list and never re-picks the recent source through autoplay. --- .../tv/ui/screens/player/PlayerViewModel.kt | 73 ++++++++++++++++--- 1 file changed, 62 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index 2df818320..9d358108a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -63,6 +63,7 @@ import com.google.gson.reflect.TypeToken import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.async +import kotlinx.coroutines.Deferred import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.Dispatchers @@ -357,6 +358,11 @@ class PlayerViewModel @Inject constructor( private var userPickedSubtitle: Boolean = false private var playbackSessionStartTime: Long = 0L private val recentSourcePlayTracker = RecentSourcePlayTracker() + private var recentSourceLoad: Deferred? = null + // Autoplay picks the title's recently played source over its own ranking, and holds its + // quick picks while that source's addon may still answer (see the source collection). + private var autoplayRecentSource: RecentPlayedSource? = null + private var holdAutoplayForRecentSource = false private fun isCurrentAnime(): Boolean = currentMediaType == MediaType.TV && @@ -1495,6 +1501,12 @@ class PlayerViewModel @Inject constructor( val HOME_SERVER_AUTOPLAY_WAIT_MS = 850L val AUTOPLAY_MAX_WINDOW_MS = 1_750L val AUTOPLAY_QUALITY_WINDOW_MS = 180L + // A title with a recently played source autoplays it. Its addon may answer after + // the quick picks below would fire, so they hold until it arrives, the search + // finishes, or this much time passes. + val RECENT_SOURCE_AUTOPLAY_WAIT_MS = 6_000L + val recentForAutoplay = recentSourceLoad?.await() + var recentWaitJob: Job? = null val collectionStartMs = System.currentTimeMillis() var autoplaySelected = false var autoplayDeferredJob: Job? = null @@ -1630,7 +1642,29 @@ class PlayerViewModel @Inject constructor( stream.behaviorHints.notWebReady != true && !stream.url.isNullOrBlank() } - if (!autoplaySelected && autoplayStreams.isNotEmpty() && autoplayDeferredJob == null && canStartAutoplay()) { + // Arrived but filtered out by the autoplay quality settings ends the hold too. + val recentArrived = findRecentSourceMatch(mergedStreams, recentForAutoplay) != null + val recentAutoplayStream = findRecentSourceMatch(autoplayStreams, recentForAutoplay) + holdAutoplayForRecentSource = recentForAutoplay != null && !recentArrived && + !progressive.isFinal && elapsedMs < RECENT_SOURCE_AUTOPLAY_WAIT_MS + if (holdAutoplayForRecentSource && recentWaitJob == null) { + recentWaitJob = launch { + delay(RECENT_SOURCE_AUTOPLAY_WAIT_MS - elapsedMs) + holdAutoplayForRecentSource = false + if (!autoplaySelected && canStartAutoplay() && + eligiblePlayerAutoplayStreams(lastMergedStreams, autoPlayMinimumQuality, autoPlayLimits).isNotEmpty() + ) { + autoplaySelected = true + autoplayDeferredJob?.cancel() + autoplayDeferredJob = null + playbackDiag("autoplayRecentWaitExpired streams=${lastMergedStreams.size}") + autoplaySelectBest(lastMergedStreams, preferredLanguage) + } + } + } + if (!autoplaySelected && autoplayStreams.isNotEmpty() && autoplayDeferredJob == null && + !holdAutoplayForRecentSource && canStartAutoplay() + ) { autoplayDeferredJob = launch { delay(AUTOPLAY_QUALITY_WINDOW_MS) if (!autoplaySelected) { @@ -1664,18 +1698,23 @@ class PlayerViewModel @Inject constructor( val autoplayTopStream = pickAutoplayTopStream(autoplayStreams, preferredLanguage) val hasRequestedPreferredStream = hasRequestedPreferredStream(autoplayStreams) val shouldSelectNow = !autoplaySelected && autoplayStreams.isNotEmpty() && canStartAutoplay() && homeServerReadyForAutoplay && ( - cacheHit || - progressive.isFinal || - hasCachedReadyStream || - hasRequestedPreferredStream || - isExcellentAutoplayCandidate(autoplayTopStream) || - elapsedMs >= AUTOPLAY_MAX_WINDOW_MS + recentAutoplayStream != null || ( + !holdAutoplayForRecentSource && ( + cacheHit || + progressive.isFinal || + hasCachedReadyStream || + hasRequestedPreferredStream || + isExcellentAutoplayCandidate(autoplayTopStream) || + elapsedMs >= AUTOPLAY_MAX_WINDOW_MS + ) + ) ) if (shouldSelectNow) { autoplaySelected = true autoplayDeferredJob?.cancel() autoplayDeferredJob = null + recentWaitJob?.cancel() Log.i( TAG, "Autoplay selecting streams=${mergedStreams.size} completed=$completed/$total final=${progressive.isFinal} cached=$hasCachedReadyStream preferred=$hasRequestedPreferredStream elapsedMs=$elapsedMs top=${autoplayTopStream?.quality}/${autoplayTopStream?.size}" @@ -1688,6 +1727,9 @@ class PlayerViewModel @Inject constructor( } } + // The search is over: nothing left to hold for. + holdAutoplayForRecentSource = false + recentWaitJob?.cancel() if (!autoplaySelected && lastMergedStreams.isNotEmpty()) { if (hasHomeServerConnections && lastMergedStreams.none { it.addonId == HomeServerRepository.ADDON_ID } @@ -2681,6 +2723,10 @@ class PlayerViewModel @Inject constructor( eligiblePlayerAutoplayStreams(streams, autoPlayMinimumQuality, autoPlayLimits), preferredLanguage ) if (healthyStreams.isEmpty()) return + // The source this title was last really watched from beats the ranking. Until its addon + // has answered (or the hold ends), late supplemental results must not pick for it. + val recentCandidate = findRecentSourceMatch(healthyStreams, autoplayRecentSource) + if (recentCandidate == null && holdAutoplayForRecentSource) return val hasExplicitPreferred = !currentPreferredBingeGroup.isNullOrBlank() || !currentPreferredAddonId.isNullOrBlank() || @@ -2703,7 +2749,7 @@ class PlayerViewModel @Inject constructor( } val stabilitySelected = pickAutoplayTopStream(healthyStreams, preferredLanguage) - val selected = if (hasExplicitPreferred) { + val selected = recentCandidate ?: if (hasExplicitPreferred) { preferredFromBingeGroup ?: preferredNavigationCandidate ?: stabilitySelected ?: healthyStreams.first() } else { stabilitySelected ?: healthyStreams.first() @@ -2711,6 +2757,7 @@ class PlayerViewModel @Inject constructor( playbackDiag( "autoplaySelected selected=${streamDiag(selected)} " + "preferredNavigation=${streamDiag(preferredNavigationCandidate)} " + + "usedRecent=${recentCandidate != null} " + "usedPreferred=${hasExplicitPreferred && (selected === preferredFromBingeGroup || selected === preferredNavigationCandidate)}" ) selectStream(selected) @@ -7683,16 +7730,20 @@ class PlayerViewModel @Inject constructor( private fun loadRecentSource(mediaType: MediaType, mediaId: Int, season: Int?, episode: Int?) { recentSourcePlayTracker.reset() - viewModelScope.launch { + autoplayRecentSource = null + holdAutoplayForRecentSource = false + recentSourceLoad = viewModelScope.async { val recent = recentPlayedSourceRepository.get(mediaType, mediaId) ?.takeIf { it.appliesTo(mediaType, mediaId, season, episode) } - ?: return@launch val stillCurrent = currentMediaType == mediaType && currentMediaId == mediaId && currentSeason == season && currentEpisode == episode + if (recent == null || !stillCurrent) return@async null + autoplayRecentSource = recent // A source that crossed the threshold in this session is newer than the stored one. - if (stillCurrent && _uiState.value.recentSource == null) { + if (_uiState.value.recentSource == null) { _uiState.value = _uiState.value.copy(recentSource = recent) } + recent } } From 06de753d5a87ff8d17b2025bd2c4032da446938f Mon Sep 17 00:00:00 2001 From: silentbil Date: Wed, 7 Oct 2026 18:51:57 +0300 Subject: [PATCH 3/3] fix(sources): close recent-source playback timing gaps from review 1. A slow source switch could save the replacement as recently played. selectStream() names the replacement in selectedStream right away, while the old video keeps playing until the new URL is resolved and applied. Playback is now credited to the source the player has actually loaded (preparedStream, set wherever a URL is applied), and nothing counts while a replacement is resolving. A failed switch keeps crediting the source that is still playing. 2. Home Server / IPTV background results could autoplay before the recent-source hold existed, since it was only set up by the first addon result. The hold is now a RecentSourceAutoplayGate armed at the start of loading, before any lookup starts, with its own 6 s timer. Every autoplay entry point goes through autoplaySelectBest, which respects it. Regression tests: RecentSourcePlaybackTimingTest (slow switch, failed switch, early background result held, hold expiry, no recent source) and RecentSourceAutoplayGateTest. --- .../tv/ui/screens/player/PlayerViewModel.kt | 100 ++++----- .../player/RecentSourceAutoplayGate.kt | 52 +++++ .../player/RecentSourceAutoplayGateTest.kt | 97 +++++++++ .../player/RecentSourcePlaybackTimingTest.kt | 198 ++++++++++++++++++ 4 files changed, 400 insertions(+), 47 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/player/RecentSourceAutoplayGate.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/ui/screens/player/RecentSourceAutoplayGateTest.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/ui/screens/player/RecentSourcePlaybackTimingTest.kt diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index 9d358108a..d61721039 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -63,6 +63,7 @@ import com.google.gson.reflect.TypeToken import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.async +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -359,10 +360,12 @@ class PlayerViewModel @Inject constructor( private var playbackSessionStartTime: Long = 0L private val recentSourcePlayTracker = RecentSourcePlayTracker() private var recentSourceLoad: Deferred? = null - // Autoplay picks the title's recently played source over its own ranking, and holds its - // quick picks while that source's addon may still answer (see the source collection). - private var autoplayRecentSource: RecentPlayedSource? = null - private var holdAutoplayForRecentSource = false + // Autoplay picks the title's recently played source over its own ranking (see the gate). + private val recentSourceGate = RecentSourceAutoplayGate() + // The source whose URL the player has loaded. selectStream() names its pick in selectedStream + // at once, but the old video keeps playing until the new URL is resolved and applied. + private var preparedStream: StreamSource? = null + private var recentSourceClock: () -> Long = { android.os.SystemClock.elapsedRealtime() } private fun isCurrentAnime(): Boolean = currentMediaType == MediaType.TV && @@ -803,6 +806,8 @@ class PlayerViewModel @Inject constructor( mediaLoadJob?.cancel() mediaLoadJob = viewModelScope.launch { + // Before any lookup starts: Home Server / IPTV background results can autoplay too. + armRecentSourceGate(recentSourceLoad?.await()) // Autoplay should always use the current highest-ranked source list. // Explicit source navigation still passes preferred fields or a URL below. val preferredAudioLanguage = resolvePreferredAudioLanguage() @@ -1009,6 +1014,7 @@ class PlayerViewModel @Inject constructor( selectedStreamUrl = resolvedProvidedUrl, savedPosition = resumeData.positionMs ) + preparedStream = resolvedProvidedStream prefetchSubtitleIndex(onlyForAutoScan = true) audioSyncOnStream() // NOTE: these background children share the load job — an uncaught exception in @@ -1501,12 +1507,6 @@ class PlayerViewModel @Inject constructor( val HOME_SERVER_AUTOPLAY_WAIT_MS = 850L val AUTOPLAY_MAX_WINDOW_MS = 1_750L val AUTOPLAY_QUALITY_WINDOW_MS = 180L - // A title with a recently played source autoplays it. Its addon may answer after - // the quick picks below would fire, so they hold until it arrives, the search - // finishes, or this much time passes. - val RECENT_SOURCE_AUTOPLAY_WAIT_MS = 6_000L - val recentForAutoplay = recentSourceLoad?.await() - var recentWaitJob: Job? = null val collectionStartMs = System.currentTimeMillis() var autoplaySelected = false var autoplayDeferredJob: Job? = null @@ -1642,28 +1642,13 @@ class PlayerViewModel @Inject constructor( stream.behaviorHints.notWebReady != true && !stream.url.isNullOrBlank() } - // Arrived but filtered out by the autoplay quality settings ends the hold too. - val recentArrived = findRecentSourceMatch(mergedStreams, recentForAutoplay) != null - val recentAutoplayStream = findRecentSourceMatch(autoplayStreams, recentForAutoplay) - holdAutoplayForRecentSource = recentForAutoplay != null && !recentArrived && - !progressive.isFinal && elapsedMs < RECENT_SOURCE_AUTOPLAY_WAIT_MS - if (holdAutoplayForRecentSource && recentWaitJob == null) { - recentWaitJob = launch { - delay(RECENT_SOURCE_AUTOPLAY_WAIT_MS - elapsedMs) - holdAutoplayForRecentSource = false - if (!autoplaySelected && canStartAutoplay() && - eligiblePlayerAutoplayStreams(lastMergedStreams, autoPlayMinimumQuality, autoPlayLimits).isNotEmpty() - ) { - autoplaySelected = true - autoplayDeferredJob?.cancel() - autoplayDeferredJob = null - playbackDiag("autoplayRecentWaitExpired streams=${lastMergedStreams.size}") - autoplaySelectBest(lastMergedStreams, preferredLanguage) - } - } - } + // While the gate holds, only the recent source may start (see RecentSourceAutoplayGate). + recentSourceGate.onStreams(mergedStreams) + if (progressive.isFinal && !supplementalSourcesStillLoading) recentSourceGate.release() + val holdForRecentSource = recentSourceGate.isHolding(recentSourceClock()) + val recentAutoplayStream = recentSourceGate.recentCandidate(autoplayStreams) if (!autoplaySelected && autoplayStreams.isNotEmpty() && autoplayDeferredJob == null && - !holdAutoplayForRecentSource && canStartAutoplay() + !holdForRecentSource && canStartAutoplay() ) { autoplayDeferredJob = launch { delay(AUTOPLAY_QUALITY_WINDOW_MS) @@ -1699,7 +1684,7 @@ class PlayerViewModel @Inject constructor( val hasRequestedPreferredStream = hasRequestedPreferredStream(autoplayStreams) val shouldSelectNow = !autoplaySelected && autoplayStreams.isNotEmpty() && canStartAutoplay() && homeServerReadyForAutoplay && ( recentAutoplayStream != null || ( - !holdAutoplayForRecentSource && ( + !holdForRecentSource && ( cacheHit || progressive.isFinal || hasCachedReadyStream || @@ -1714,7 +1699,6 @@ class PlayerViewModel @Inject constructor( autoplaySelected = true autoplayDeferredJob?.cancel() autoplayDeferredJob = null - recentWaitJob?.cancel() Log.i( TAG, "Autoplay selecting streams=${mergedStreams.size} completed=$completed/$total final=${progressive.isFinal} cached=$hasCachedReadyStream preferred=$hasRequestedPreferredStream elapsedMs=$elapsedMs top=${autoplayTopStream?.quality}/${autoplayTopStream?.size}" @@ -1727,9 +1711,11 @@ class PlayerViewModel @Inject constructor( } } - // The search is over: nothing left to hold for. - holdAutoplayForRecentSource = false - recentWaitJob?.cancel() + // The addon search is over. Home Server / IPTV may still bring the recent source; + // if they don't, the gate's own timer picks once it runs out. + if (homeServerAppendJob?.isActive != true && vodAppendJob?.isActive != true) { + recentSourceGate.release() + } if (!autoplaySelected && lastMergedStreams.isNotEmpty()) { if (hasHomeServerConnections && lastMergedStreams.none { it.addonId == HomeServerRepository.ADDON_ID } @@ -2723,10 +2709,11 @@ class PlayerViewModel @Inject constructor( eligiblePlayerAutoplayStreams(streams, autoPlayMinimumQuality, autoPlayLimits), preferredLanguage ) if (healthyStreams.isEmpty()) return - // The source this title was last really watched from beats the ranking. Until its addon - // has answered (or the hold ends), late supplemental results must not pick for it. - val recentCandidate = findRecentSourceMatch(healthyStreams, autoplayRecentSource) - if (recentCandidate == null && holdAutoplayForRecentSource) return + // Every autoplay entry point lands here. The source this title was last really watched + // from beats the ranking, and while the gate holds nothing else may start. + recentSourceGate.onStreams(streams) + val recentCandidate = recentSourceGate.recentCandidate(healthyStreams) + if (recentCandidate == null && recentSourceGate.isHolding(recentSourceClock())) return val hasExplicitPreferred = !currentPreferredBingeGroup.isNullOrBlank() || !currentPreferredAddonId.isNullOrBlank() || @@ -3248,6 +3235,7 @@ class PlayerViewModel @Inject constructor( error = null, isSetupError = false ) + preparedStream = resolvedStream prefetchSubtitleIndex(onlyForAutoScan = true) audioSyncOnStream() @@ -7730,15 +7718,14 @@ class PlayerViewModel @Inject constructor( private fun loadRecentSource(mediaType: MediaType, mediaId: Int, season: Int?, episode: Int?) { recentSourcePlayTracker.reset() - autoplayRecentSource = null - holdAutoplayForRecentSource = false + recentSourceGate.disarm() + preparedStream = null recentSourceLoad = viewModelScope.async { val recent = recentPlayedSourceRepository.get(mediaType, mediaId) ?.takeIf { it.appliesTo(mediaType, mediaId, season, episode) } val stillCurrent = currentMediaType == mediaType && currentMediaId == mediaId && currentSeason == season && currentEpisode == episode if (recent == null || !stillCurrent) return@async null - autoplayRecentSource = recent // A source that crossed the threshold in this session is newer than the stored one. if (_uiState.value.recentSource == null) { _uiState.value = _uiState.value.copy(recentSource = recent) @@ -7747,16 +7734,35 @@ class PlayerViewModel @Inject constructor( } } - /** Records the selected source as this title's recently played one once it has really played. */ + /** Arms [recentSourceGate] for this load. If it runs out with nothing started, autoplay picks from what was found. */ + private fun CoroutineScope.armRecentSourceGate(recent: RecentPlayedSource?) { + recentSourceGate.arm(recent, recentSourceClock()) + if (recent == null) return + launch { + delay(recentSourceGate.remainingMs(recentSourceClock())) + recentSourceGate.release() + if (canStartAutoplay()) { + val streams = _uiState.value.streams + playbackDiag("autoplayRecentWaitExpired streams=${streams.size}") + autoplaySelectBest(streams, _uiState.value.preferredAudioLanguage.ifBlank { "en" }) + } + } + } + + /** Records the playing source as this title's recently played one once it has really played. */ private fun noteRecentSourcePlayback(positionMs: Long, isPlaying: Boolean) { if (currentIsLiveStreamPlayback || currentMediaId <= 0) return val state = _uiState.value - val stream = state.selectedStream ?: return + // Credit the source the player has loaded, not selectedStream: during a switch that already + // names the replacement while the old video keeps playing. Nothing counts until the + // replacement is applied (its new nonce restarts the count) or the switch fails. + val stream = preparedStream ?: return + val replacementResolving = streamSelectionJob?.isActive == true val reached = recentSourcePlayTracker.onProgress( selectionId = state.streamSelectionNonce.toLong(), positionMs = positionMs, - isPlaying = isPlaying, - nowMs = android.os.SystemClock.elapsedRealtime() + isPlaying = isPlaying && !replacementResolving, + nowMs = recentSourceClock() ) if (!reached) return fun recordOf(source: StreamSource) = RecentPlayedSource.of( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/RecentSourceAutoplayGate.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/RecentSourceAutoplayGate.kt new file mode 100644 index 000000000..334187504 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/RecentSourceAutoplayGate.kt @@ -0,0 +1,52 @@ +package com.arflix.tv.ui.screens.player + +import com.arflix.tv.data.model.RecentPlayedSource +import com.arflix.tv.data.model.StreamSource +import com.arflix.tv.data.model.findRecentSourceMatch + +/** + * Gives a title's recently played source its chance to autoplay. Autoplay normally starts + * within a couple of seconds, as soon as any good source appears, and Home Server / IPTV + * lookups can answer before the addons do; the remembered source's addon may answer later. + * + * Armed before any lookup starts. While it holds, autoplay may start the recent source and + * nothing else. It releases when the recent source arrives (even if the autoplay settings then + * filter it out), when the search is over, or after [waitMs]. + */ +internal class RecentSourceAutoplayGate( + private val waitMs: Long = RECENT_SOURCE_AUTOPLAY_WAIT_MS, +) { + var recent: RecentPlayedSource? = null + private set + private var deadlineMs = 0L + private var released = true + + fun arm(recent: RecentPlayedSource?, nowMs: Long) { + this.recent = recent + deadlineMs = nowMs + waitMs + released = recent == null + } + + fun disarm() = arm(null, 0L) + + fun release() { + released = true + } + + /** Releases once [streams], everything found so far, include the recent source. */ + fun onStreams(streams: List) { + if (!released && findRecentSourceMatch(streams, recent) != null) released = true + } + + fun isHolding(nowMs: Long): Boolean = !released && nowMs < deadlineMs + + fun remainingMs(nowMs: Long): Long = (deadlineMs - nowMs).coerceAtLeast(0L) + + /** The recent source among the streams autoplay may start, or null. */ + fun recentCandidate(candidates: List): StreamSource? = + findRecentSourceMatch(candidates, recent) + + companion object { + const val RECENT_SOURCE_AUTOPLAY_WAIT_MS = 6_000L + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/player/RecentSourceAutoplayGateTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/player/RecentSourceAutoplayGateTest.kt new file mode 100644 index 000000000..5e5df1e8e --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/player/RecentSourceAutoplayGateTest.kt @@ -0,0 +1,97 @@ +package com.arflix.tv.ui.screens.player + +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.model.RecentPlayedSource +import com.arflix.tv.data.model.StreamSource +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class RecentSourceAutoplayGateTest { + + private val recentStream = stream(addonId = "torrentio", url = "https://torrentio/play/recent") + private val iptvStream = stream(addonId = "iptv_vod", url = "https://iptv/movie.mkv") + private val recent = RecentPlayedSource.of(MediaType.MOVIE, 7, null, null, recentStream, playedAtMs = 1L) + + @Test + fun withoutARecentSourceItNeverHolds() { + val gate = RecentSourceAutoplayGate() + gate.arm(null, nowMs = 0L) + + assertThat(gate.isHolding(0L)).isFalse() + } + + @Test + fun holdsFromTheMomentItIsArmedBeforeAnyResults() { + val gate = RecentSourceAutoplayGate() + gate.arm(recent, nowMs = 0L) + + assertThat(gate.isHolding(0L)).isTrue() + } + + @Test + fun anEarlyBackgroundResultDoesNotReleaseIt() { + val gate = RecentSourceAutoplayGate() + gate.arm(recent, nowMs = 0L) + + // IPTV / Home Server answer before the addons: nothing for autoplay to start yet. + gate.onStreams(listOf(iptvStream)) + + assertThat(gate.isHolding(300L)).isTrue() + assertThat(gate.recentCandidate(listOf(iptvStream))).isNull() + } + + @Test + fun releasesWhenTheRecentSourceArrives() { + val gate = RecentSourceAutoplayGate() + gate.arm(recent, nowMs = 0L) + + gate.onStreams(listOf(iptvStream, recentStream)) + + assertThat(gate.isHolding(1_000L)).isFalse() + assertThat(gate.recentCandidate(listOf(iptvStream, recentStream))).isSameInstanceAs(recentStream) + } + + @Test + fun releasesWhenTheRecentSourceArrivesButAutoplaySettingsFilterItOut() { + val gate = RecentSourceAutoplayGate() + gate.arm(recent, nowMs = 0L) + + gate.onStreams(listOf(iptvStream, recentStream)) + + assertThat(gate.isHolding(1_000L)).isFalse() + assertThat(gate.recentCandidate(listOf(iptvStream))).isNull() + } + + @Test + fun runsOutAfterTheWait() { + val gate = RecentSourceAutoplayGate(waitMs = 6_000L) + gate.arm(recent, nowMs = 1_000L) + + assertThat(gate.isHolding(6_999L)).isTrue() + assertThat(gate.remainingMs(6_999L)).isEqualTo(1L) + assertThat(gate.isHolding(7_000L)).isFalse() + assertThat(gate.remainingMs(9_000L)).isEqualTo(0L) + } + + @Test + fun releaseAndDisarmEndTheHold() { + val gate = RecentSourceAutoplayGate() + gate.arm(recent, nowMs = 0L) + gate.release() + assertThat(gate.isHolding(0L)).isFalse() + + gate.arm(recent, nowMs = 0L) + gate.disarm() + assertThat(gate.isHolding(0L)).isFalse() + assertThat(gate.recent).isNull() + } + + private fun stream(addonId: String, url: String) = StreamSource( + source = "Movie 1080p", + addonName = addonId, + addonId = addonId, + quality = "1080p", + size = "4 GB", + url = url, + ) +} diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/player/RecentSourcePlaybackTimingTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/player/RecentSourcePlaybackTimingTest.kt new file mode 100644 index 000000000..5429cd7e0 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/player/RecentSourcePlaybackTimingTest.kt @@ -0,0 +1,198 @@ +package com.arflix.tv.ui.screens.player + +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewModelScope +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.model.RecentPlayedSource +import com.arflix.tv.data.model.StreamSource +import com.arflix.tv.data.repository.RecentPlayedSourceRepository +import com.arflix.tv.data.repository.StreamRepository +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.ConscryptMode + +/** Playback-timing regressions for the recently played source (PR review on #790). */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE, application = android.app.Application::class) +@ConscryptMode(ConscryptMode.Mode.OFF) +class RecentSourcePlaybackTimingTest { + private lateinit var model: PlayerViewModel + private val store = ViewModelStore() + private val streams = mockk(relaxed = true) + private val recentRepository = mockk(relaxed = true) + private var clockMs = 0L + private var positionMs = 0L + + private val sourceA = stream("a") + private val sourceB = stream("b") + private val iptv = stream("iptv", addonId = "iptv_vod") + + @Before fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + coEvery { streams.resolveStreamForPlayback(any()) } coAnswers { firstArg() } + model = PlayerViewModel( + context = mockk(relaxed = true), + profileManager = mockk(relaxed = true), + mediaRepository = mockk(relaxed = true), + streamRepository = streams, + traktRepository = mockk(relaxed = true), + remoteSyncManager = mockk(relaxed = true), + watchHistoryRepository = mockk(relaxed = true), + cloudSyncRepository = mockk(relaxed = true), + launcherContinueWatchingRepository = mockk(relaxed = true), + animeMapper = mockk(relaxed = true), + tmdbApi = mockk(relaxed = true), + skipIntroRepository = mockk(relaxed = true), + playbackTelemetryRepository = mockk(relaxed = true), + pluginManager = mockk(relaxed = true), + streamIntegrationRepository = mockk(relaxed = true), + recentPlayedSourceRepository = recentRepository + ) + store.put("player", model) + field("currentMediaId").setInt(model, 42) + val clock: () -> Long = { clockMs } + field("recentSourceClock").set(model, clock) + state().value = state().value.copy(streams = listOf(sourceA, sourceB, iptv)) + } + + @After fun tearDown() { + val job = model.viewModelScope.coroutineContext[Job] + try { + store.clear() + runBlocking { withTimeout(5_000) { job?.join() } } + } finally { + Dispatchers.resetMain() + } + } + + // Review case 1: selectedStream names the replacement before its URL is applied, while the + // old video keeps playing and reporting progress. + + @Test fun aReplacementStillResolvingIsNotSavedFromTheOldVideosTime() { + model.selectStream(sourceA) + play(seconds = 6) // baseline + 4 s counted + + val bResolved = CompletableDeferred() + coEvery { streams.resolveStreamForPlayback(sourceB) } coAnswers { bResolved.await() } + model.selectStream(sourceB) + assertSame(sourceB, model.uiState.value.selectedStream) + play(seconds = 10) + + verify(exactly = 0) { recentRepository.save(any(), any()) } + + bResolved.complete(sourceB) + play(seconds = 12) // the first report after the switch only sets the baseline + + val saved = slot() + verify(exactly = 1) { recentRepository.save(any(), capture(saved)) } + assertEquals(sourceB.url, saved.captured.url) + } + + @Test fun aFailedSwitchKeepsCreditingTheSourceStillPlaying() { + model.selectStream(sourceA) + play(seconds = 6) // baseline + 4 s counted + + // B can't produce a URL: the switch fails and A never stops playing. + val unplayable = sourceB.copy(url = null) + model.selectStream(unplayable) + assertSame(unplayable, model.uiState.value.selectedStream) + play(seconds = 6) + + val saved = slot() + verify(exactly = 1) { recentRepository.save(any(), capture(saved)) } + assertEquals(sourceA.url, saved.captured.url) + } + + // Review case 2: Home Server / IPTV results reach autoplay through autoplaySelectBest, + // possibly before any addon result. Once armed, the gate holds there too. + + @Test fun anEarlyBackgroundResultCannotAutoplayWhileTheRecentSourceIsAwaited() { + armGate(recentOf(sourceB)) + + autoplaySelectBest(listOf(iptv)) + assertNull(model.uiState.value.selectedStream) + + autoplaySelectBest(listOf(iptv, sourceB)) + assertSame(sourceB, model.uiState.value.selectedStream) + } + + @Test fun anEarlyBackgroundResultAutoplaysOnceTheHoldRunsOut() { + armGate(recentOf(sourceB)) + clockMs += RecentSourceAutoplayGate.RECENT_SOURCE_AUTOPLAY_WAIT_MS + + autoplaySelectBest(listOf(iptv)) + + assertSame(iptv, model.uiState.value.selectedStream) + } + + @Test fun withoutARecentSourceAutoplayIsNotHeld() { + armGate(null) + + autoplaySelectBest(listOf(iptv)) + + assertSame(iptv, model.uiState.value.selectedStream) + } + + /** The playing video reports progress every 2 s for [seconds], as PlayerScreen's loop does. */ + private fun play(seconds: Int) { + repeat(seconds / 2) { + clockMs += 2_000L + positionMs += 2_000L + noteRecentSourcePlayback(positionMs, isPlaying = true) + } + } + + private fun noteRecentSourcePlayback(positionMs: Long, isPlaying: Boolean) { + PlayerViewModel::class.java.getDeclaredMethod( + "noteRecentSourcePlayback", Long::class.javaPrimitiveType, Boolean::class.javaPrimitiveType + ).apply { isAccessible = true }.invoke(model, positionMs, isPlaying) + } + + private fun autoplaySelectBest(candidates: List) { + PlayerViewModel::class.java.getDeclaredMethod( + "autoplaySelectBest", List::class.java, String::class.java + ).apply { isAccessible = true }.invoke(model, candidates, "en") + } + + private fun armGate(recent: RecentPlayedSource?) { + (field("recentSourceGate").get(model) as RecentSourceAutoplayGate).arm(recent, clockMs) + } + + private fun recentOf(source: StreamSource) = + RecentPlayedSource.of(MediaType.MOVIE, 42, null, null, source, playedAtMs = 1L) + + private fun stream(name: String, addonId: String = "torrentio") = StreamSource( + source = "Movie 1080p $name", + addonName = addonId, + addonId = addonId, + quality = "1080p", + size = "4 GB", + url = "https://$addonId.example/play/$name.mkv", + ) + + private fun field(name: String) = PlayerViewModel::class.java.getDeclaredField(name).apply { isAccessible = true } + + @Suppress("UNCHECKED_CAST") + private fun state() = field("_uiState").get(model) as MutableStateFlow +}