Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.provider.BaseColumns
import android.util.LruCache
import androidx.annotation.RequiresApi
import androidx.tvprovider.media.tv.Channel
import androidx.tvprovider.media.tv.ChannelLogoUtils
Expand All @@ -25,6 +26,12 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton

Expand All @@ -43,8 +50,11 @@ class LauncherContinueWatchingRepository @Inject constructor(
private val traktRepository: TraktRepository,
private val remoteSyncManager: com.arflix.tv.data.repository.sync.RemoteSyncManager,
private val watchHistoryRepository: WatchHistoryRepository,
private val streamRepository: StreamRepository
private val streamRepository: StreamRepository,
private val mediaRepository: MediaRepository
) {

private val titleCache = LruCache<String, String>(200)
companion object {
private const val TAG = "LauncherCW"
private const val CHANNEL_INTERNAL_ID = "arvio_continue_watching_channel"
Expand Down Expand Up @@ -109,43 +119,81 @@ class LauncherContinueWatchingRepository @Inject constructor(
addons = installedAddons
)
}
if (filteredPrimary.isNotEmpty()) {
return filteredPrimary.take(Constants.MAX_CONTINUE_WATCHING)

val selectedItems = if (filteredPrimary.isNotEmpty()) {
filteredPrimary.take(Constants.MAX_CONTINUE_WATCHING)
} else {
val historyFallback = runCatching { watchHistoryRepository.getContinueWatching() }.getOrDefault(emptyList())
historyFallback
.sortedByDescending { it.updated_at ?: it.paused_at.orEmpty() }
.map { entry ->
ContinueWatchingItem(
id = entry.show_tmdb_id,
title = entry.title.orEmpty(),
mediaType = if (entry.media_type == "tv") MediaType.TV else MediaType.MOVIE,
progress = (entry.progress * 100f).toInt().coerceIn(0, 99),
season = entry.season,
episode = entry.episode,
episodeTitle = entry.episode_title,
posterPath = entry.poster_path,
backdropPath = entry.backdrop_path,
resumePositionSeconds = entry.position_seconds,
durationSeconds = entry.duration_seconds,
streamAddonId = entry.stream_addon_id
)
}
.filterNot { item ->
SportsAddonCapabilities.isLiveStreamOrSportsItem(
mediaType = item.mediaType,
id = item.id,
streamAddonId = item.streamAddonId,
title = item.title,
addons = installedAddons
)
}
.distinctBy { "${it.mediaType}:${it.id}:${it.season ?: -1}:${it.episode ?: -1}" }
.take(Constants.MAX_CONTINUE_WATCHING)
}

val historyFallback = runCatching { watchHistoryRepository.getContinueWatching() }.getOrDefault(emptyList())
return historyFallback
.sortedByDescending { it.updated_at ?: it.paused_at.orEmpty() }
.map { entry ->
ContinueWatchingItem(
id = entry.show_tmdb_id,
title = entry.title.orEmpty(),
mediaType = if (entry.media_type == "tv") MediaType.TV else MediaType.MOVIE,
progress = (entry.progress * 100f).toInt().coerceIn(0, 99),
season = entry.season,
episode = entry.episode,
episodeTitle = entry.episode_title,
posterPath = entry.poster_path,
backdropPath = entry.backdrop_path,
resumePositionSeconds = entry.position_seconds,
durationSeconds = entry.duration_seconds,
streamAddonId = entry.stream_addon_id
)
}
.filterNot { item ->
SportsAddonCapabilities.isLiveStreamOrSportsItem(
mediaType = item.mediaType,
id = item.id,
streamAddonId = item.streamAddonId,
title = item.title,
addons = installedAddons
)
}
.distinctBy { "${it.mediaType}:${it.id}:${it.season ?: -1}:${it.episode ?: -1}" }
.take(Constants.MAX_CONTINUE_WATCHING)
// Limit of 4 simultaneous calls to avoid saturating the network
val semaphore = Semaphore(4)
val currentLang = Locale.getDefault().toLanguageTag()

return coroutineScope {
selectedItems.map { item ->
async {
semaphore.withPermit {
// Cache keys including current language
val titleKey = "${currentLang}_${item.mediaType}_${item.id}"
val epKey = "${currentLang}_tv_${item.id}_${item.season}_${item.episode}"

val localizedTitle = titleCache.get(titleKey) ?: runCatching {
val t = if (item.mediaType == MediaType.TV) {
mediaRepository.getLightweightTvTitle(item.id)
} else {
mediaRepository.getLightweightMovieTitle(item.id)
}
t?.takeIf { it.isNotBlank() }?.also { titleCache.put(titleKey, it) }
}.getOrNull() ?: item.title

val resolvedEpisodeTitle = if (item.mediaType == MediaType.TV && item.season != null && item.episode != null) {
titleCache.get(epKey) ?: runCatching {
val ep = mediaRepository.getLightweightEpisodeTitle(item.id, item.season, item.episode)
ep?.takeIf { it.isNotBlank() }?.also { titleCache.put(epKey, it) }
}.getOrNull()
} else {
null
} ?: item.episodeTitle

item.copy(
title = localizedTitle,
episodeTitle = resolvedEpisodeTitle
)
}
}
}.awaitAll()
}
}


@RequiresApi(Build.VERSION_CODES.O)
private fun syncPublishedRows(items: List<ContinueWatchingItem>) {
val channelId = ensurePreviewChannel() ?: return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2907,7 +2907,7 @@ class MediaRepository @Inject constructor(
* Get movie details (cached)
*/
suspend fun getMovieDetails(movieId: Int): MediaItem {
val cacheKey = "movie_$movieId"
val cacheKey = "movie_${movieId}_${contentLanguage ?: "default"}"
getFromCache(detailsCache, cacheKey)?.let { cached ->
if (movieId < 0 && cached.isHomeServer) return cached
if (cacheKey in fullDetailsCacheKeys) {
Expand Down Expand Up @@ -2937,7 +2937,7 @@ class MediaRepository @Inject constructor(
* Get TV show details (cached)
*/
suspend fun getTvDetails(tvId: Int): MediaItem {
val cacheKey = "tv_$tvId"
val cacheKey = "tv_${tvId}_${contentLanguage ?: "default"}"
getFromCache(detailsCache, cacheKey)?.let { cached ->
if (tvId < 0 && cached.isHomeServer) return cached
if (cacheKey in fullDetailsCacheKeys) {
Expand All @@ -2963,6 +2963,29 @@ class MediaRepository @Inject constructor(
return item
}

/**
* Lightweight calls for LauncherContinueWatchingRepository to avoid heavy IMDb rating/caching tasks.
*/
suspend fun getLightweightMovieTitle(movieId: Int): String? {
return runCatching {
tmdbApi.getMovieDetails(movieId, apiKey, language = contentLanguage).title
}.getOrNull()
}

suspend fun getLightweightTvTitle(tvId: Int): String? {
return runCatching {
// TMDB uses 'name' for series instead of 'title'
tmdbApi.getTvDetails(tvId, apiKey, language = contentLanguage).name
}.getOrNull()
}

suspend fun getLightweightEpisodeTitle(tvId: Int, seasonNumber: Int, episodeNumber: Int): String? {
return runCatching {
val episodes = getSeasonEpisodes(tvId, seasonNumber)
episodes.firstOrNull { it.episodeNumber == episodeNumber }?.name
}.getOrNull()
}

/**
* Get the TMDB collection (franchise) reference for a movie.
* Calls /movie/{id} directly to access the `belongs_to_collection` field,
Expand Down
37 changes: 35 additions & 2 deletions app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1147,7 +1147,7 @@ class HomeViewModel @Inject constructor(
}
}

private fun loadContinueWatchingCache(): List<ContinueWatchingItem> = runCatching {
private suspend fun loadContinueWatchingCache(): List<ContinueWatchingItem> = runCatching {
val file = continueWatchingCacheFile()
if (!file.exists() || file.length() > maxContinueWatchingCacheBytes) return emptyList()
val json = file.readText()
Expand All @@ -1156,7 +1156,40 @@ class HomeViewModel @Inject constructor(
.getParameterized(MutableList::class.java, ContinueWatchingItem::class.java)
.type
val parsed: List<ContinueWatchingItem> = gson.fromJson(json, type) ?: emptyList()
parsed.filter { it.id > 0 && it.title.isNotBlank() }.take(Constants.MAX_CONTINUE_WATCHING)

// We retrieve the items from the cache
val items = parsed.filter { it.id > 0 && it.title.isNotBlank() }.take(Constants.MAX_CONTINUE_WATCHING)

// We translate them on the fly before sending them to the screen
val semaphore = kotlinx.coroutines.sync.Semaphore(4)
kotlinx.coroutines.coroutineScope {
items.map { item ->
async {
semaphore.withPermit {
val localizedTitle = runCatching {
if (item.mediaType.name == "TV") {
mediaRepository.getLightweightTvTitle(item.id)
} else {
mediaRepository.getLightweightMovieTitle(item.id)
}
}.getOrNull()?.takeIf { it.isNotBlank() } ?: item.title

val resolvedEpisodeTitle = if (item.mediaType.name == "TV" && item.season != null && item.episode != null) {
runCatching {
mediaRepository.getLightweightEpisodeTitle(item.id, item.season, item.episode)
}.getOrNull()
} else {
null
} ?: item.episodeTitle

item.copy(
title = localizedTitle,
episodeTitle = resolvedEpisodeTitle
)
}
}
}.awaitAll()
}
}.getOrDefault(emptyList())

private fun loadCategoriesCache(): List<Category> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1390,6 +1390,9 @@ class SettingsViewModel @Inject constructor(
mediaRepository.contentLanguage = lang
_uiState.value = _uiState.value.copy(contentLanguage = lang)
syncLocalStateToCloud(silent = true)

// Refresh the Launcher "Keep watching" with the new language
launcherContinueWatchingRepository.refreshForCurrentProfile()
}
}

Expand Down
Loading