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..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 @@ -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 @@ -60,6 +63,8 @@ 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 import kotlinx.coroutines.Dispatchers @@ -160,6 +165,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 +304,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 +358,14 @@ 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 var recentSourceLoad: Deferred? = null + // 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 && @@ -772,8 +788,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 @@ -788,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() @@ -994,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 @@ -1621,7 +1642,14 @@ class PlayerViewModel @Inject constructor( stream.behaviorHints.notWebReady != true && !stream.url.isNullOrBlank() } - if (!autoplaySelected && autoplayStreams.isNotEmpty() && autoplayDeferredJob == null && canStartAutoplay()) { + // 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 && + !holdForRecentSource && canStartAutoplay() + ) { autoplayDeferredJob = launch { delay(AUTOPLAY_QUALITY_WINDOW_MS) if (!autoplaySelected) { @@ -1655,12 +1683,16 @@ 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 || ( + !holdForRecentSource && ( + cacheHit || + progressive.isFinal || + hasCachedReadyStream || + hasRequestedPreferredStream || + isExcellentAutoplayCandidate(autoplayTopStream) || + elapsedMs >= AUTOPLAY_MAX_WINDOW_MS + ) + ) ) if (shouldSelectNow) { @@ -1679,6 +1711,11 @@ class PlayerViewModel @Inject constructor( } } + // 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 } @@ -2672,6 +2709,11 @@ class PlayerViewModel @Inject constructor( eligiblePlayerAutoplayStreams(streams, autoPlayMinimumQuality, autoPlayLimits), preferredLanguage ) if (healthyStreams.isEmpty()) 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() || @@ -2694,7 +2736,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() @@ -2702,6 +2744,7 @@ class PlayerViewModel @Inject constructor( playbackDiag( "autoplaySelected selected=${streamDiag(selected)} " + "preferredNavigation=${streamDiag(preferredNavigationCandidate)} " + + "usedRecent=${recentCandidate != null} " + "usedPreferred=${hasExplicitPreferred && (selected === preferredFromBingeGroup || selected === preferredNavigationCandidate)}" ) selectStream(selected) @@ -3192,6 +3235,7 @@ class PlayerViewModel @Inject constructor( error = null, isSetupError = false ) + preparedStream = resolvedStream prefetchSubtitleIndex(onlyForAutoScan = true) audioSyncOnStream() @@ -7672,6 +7716,71 @@ class PlayerViewModel @Inject constructor( ) } + private fun loadRecentSource(mediaType: MediaType, mediaId: Int, season: Int?, episode: Int?) { + recentSourcePlayTracker.reset() + 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 + // 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) + } + recent + } + } + + /** 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 + // 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 && !replacementResolving, + nowMs = recentSourceClock() + ) + 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 +7789,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/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/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/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/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 + } +} 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 +}