diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index 2fa54ebd1..95b0ba9aa 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -4925,7 +4925,11 @@ class IptvRepository @Inject constructor( @SerializedName("container_extension") val containerExtension: String? = null, @SerializedName(value = "imdb", alternate = ["imdb_id", "imdbid"]) val imdb: String? = null, @SerializedName(value = "tmdb", alternate = ["tmdb_id", "tmdbid"]) val tmdb: String? = null, - @SerializedName("category_id") val categoryId: String? = null + @SerializedName("category_id") val categoryId: String? = null, + // Browse-only fields (IPTV VOD screen): artwork, rating, added timestamp. + @SerializedName("stream_icon") val streamIcon: String? = null, + val rating: String? = null, + val added: String? = null ) private data class XtreamSeriesItem( @@ -4933,7 +4937,12 @@ class IptvRepository @Inject constructor( val name: String? = null, @SerializedName(value = "imdb", alternate = ["imdb_id", "imdbid"]) val imdb: String? = null, @SerializedName(value = "tmdb", alternate = ["tmdb_id", "tmdbid"]) val tmdb: String? = null, - @SerializedName("category_id") val categoryId: String? = null + @SerializedName("category_id") val categoryId: String? = null, + // Browse-only fields (IPTV VOD screen). + val cover: String? = null, + val rating: String? = null, + @SerializedName(value = "releaseDate", alternate = ["release_date"]) val releaseDate: String? = null, + @SerializedName("last_modified") val lastModified: String? = null ) private data class XtreamSeriesEpisode( @@ -7600,10 +7609,10 @@ class IptvRepository @Inject constructor( } private fun vodDiskCacheFile(creds: XtreamCredentials): File = - File(xtreamDiskCacheDir(), "vod_${xtreamDiskCacheHash(creds)}.json") + File(xtreamDiskCacheDir(), "vod_v2_${xtreamDiskCacheHash(creds)}.json") private fun seriesDiskCacheFile(creds: XtreamCredentials): File = - File(xtreamDiskCacheDir(), "series_${xtreamDiskCacheHash(creds)}.json") + File(xtreamDiskCacheDir(), "series_v2_${xtreamDiskCacheHash(creds)}.json") private fun readDiskCache(file: File, type: Type): XtreamDiskCache? { val parentExists = file.parentFile?.exists() == true @@ -12069,6 +12078,96 @@ class IptvRepository @Inject constructor( .mapNotNull { it.toInfoOrNull() } } + // ── IPTV VOD browsing (the VOD tab next to TV) ────────────────────────── + + data class IptvVodBrowseCategory(val id: String, val name: String) + + data class IptvVodBrowseItem( + val key: String, + val isSeries: Boolean, + val name: String, + val posterUrl: String?, + val year: Int?, + val rating: String?, + val categoryId: String?, + val tmdbId: Int?, + /** Direct play URL (movies only; series resolve episodes through Details). */ + val streamUrl: String?, + val addedAt: Long + ) + + data class IptvVodBrowseCatalog( + val categories: List, + val items: List + ) + + /** True when at least one active Xtream playlist can provide movies or series. */ + suspend fun hasVodBrowseSource(): Boolean { + val config = observeConfig().first() + return xtreamCredentialsForVodImport(config).isNotEmpty() || + xtreamCredentialsForSeriesImport(config).isNotEmpty() + } + + /** + * The provider's full movie or series catalogue for browsing, in the provider's + * own categories. Uses the same memory/disk caches as VOD source matching, so + * opening the screen after the first load is instant. + */ + suspend fun getVodBrowseCatalog(series: Boolean): IptvVodBrowseCatalog = withContext(Dispatchers.IO) { + val config = observeConfig().first() + if (series) { + val creds = xtreamCredentialsForSeriesImport(config).firstOrNull() + ?: return@withContext IptvVodBrowseCatalog(emptyList(), emptyList()) + val categories = loadXtreamSeriesCategoriesInternal(creds, allowNetwork = true) + .mapNotNull { it.toInfoOrNull() } + .map { IptvVodBrowseCategory(it.categoryId, it.categoryName) } + val items = loadXtreamSeriesList(creds).mapNotNull { item -> + val id = item.seriesId ?: return@mapNotNull null + val name = item.name?.trim().orEmpty().ifBlank { return@mapNotNull null } + IptvVodBrowseItem( + key = "series_$id", + isSeries = true, + name = name, + posterUrl = item.cover?.trim()?.takeIf { it.startsWith("http") }, + year = parseBrowseYear(item.releaseDate) ?: parseBrowseYear(name), + rating = item.rating?.trim()?.takeIf { it.isNotEmpty() && it != "0" }, + categoryId = item.categoryId, + tmdbId = normalizeTmdbId(item.tmdb)?.toIntOrNull(), + streamUrl = null, + addedAt = item.lastModified?.trim()?.toLongOrNull() ?: 0L + ) + } + IptvVodBrowseCatalog(categories, items) + } else { + val creds = xtreamCredentialsForVodImport(config).firstOrNull() + ?: return@withContext IptvVodBrowseCatalog(emptyList(), emptyList()) + val categories = loadXtreamVodCategoriesInternal(creds, allowNetwork = true) + .mapNotNull { it.toInfoOrNull() } + .map { IptvVodBrowseCategory(it.categoryId, it.categoryName) } + val items = loadXtreamVodStreams(creds).mapNotNull { item -> + val id = item.streamId ?: return@mapNotNull null + val name = item.name?.trim().orEmpty().ifBlank { return@mapNotNull null } + val ext = item.containerExtension?.trim()?.ifBlank { null } ?: "mp4" + IptvVodBrowseItem( + key = "movie_$id", + isSeries = false, + name = name, + posterUrl = item.streamIcon?.trim()?.takeIf { it.startsWith("http") }, + year = parseBrowseYear(item.year) ?: parseBrowseYear(name), + rating = item.rating?.trim()?.takeIf { it.isNotEmpty() && it != "0" }, + categoryId = item.categoryId, + tmdbId = normalizeTmdbId(item.tmdb)?.toIntOrNull(), + streamUrl = "${creds.baseUrl}/movie/${creds.username}/${creds.password}/$id.$ext", + addedAt = item.added?.trim()?.toLongOrNull() ?: 0L + ) + } + IptvVodBrowseCatalog(categories, items) + } + } + + private fun parseBrowseYear(raw: String?): Int? = + raw?.let { Regex("(19|20)\\d{2}").find(it)?.value?.toIntOrNull() } + private fun XtreamVodCategoryWire.toInfoOrNull(): XtreamVodCategoryInfo? { val id = categoryId?.trim().orEmpty() val name = categoryName?.trim().orEmpty() diff --git a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt index 31ae3c718..02b20ef58 100644 --- a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt +++ b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt @@ -33,6 +33,7 @@ import com.arflix.tv.ui.screens.settings.SettingsScreen import com.arflix.tv.ui.screens.settings.telegram.TelegramSettingsScreen import com.arflix.tv.ui.screens.tv.live.LiveTvScreen import com.arflix.tv.ui.screens.watchlist.WatchlistScreen +import com.arflix.tv.data.model.IptvVodSourceIds import com.arflix.tv.ui.screens.profile.ProfileSelectionScreen import com.arflix.tv.util.LocalDeviceType @@ -284,6 +285,18 @@ fun AppNavigation( onNavigateToDetails = { mediaType, mediaId -> navController.navigate(Screen.Details.createRoute(mediaType, mediaId)) }, + // An IPTV movie TMDB does not know: play the provider's copy. + onPlayIptvVod = { mediaId, streamUrl, title -> + navController.navigate( + Screen.Player.createRoute( + mediaType = MediaType.MOVIE, + mediaId = mediaId, + streamUrl = streamUrl, + preferredAddonId = IptvVodSourceIds.XTREAM, + preferredSourceName = title + ) + ) + }, onNavigateToHome = { navigateHome() }, onNavigateToSearch = { navigateTopLevel(Screen.Search.route) }, onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) }, 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..c7f0baf30 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 @@ -7720,7 +7720,7 @@ class PlayerViewModel @Inject constructor( isLiveStream = currentIsLiveStreamPlayback, addons = currentInstalledAddons, isAddonNative = mediaRepository.isAddonNative(currentMediaId) - ) + ) || currentMediaId <= 0 // IPTV VOD played without a TMDB match: no scrobble/history // Scrobble start/pause/updates with debounce if (!isLiveStreamOrSports && isPlaying && !lastIsPlaying) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/vod/IptvVodViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/vod/IptvVodViewModel.kt new file mode 100644 index 000000000..dce1a32f2 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/vod/IptvVodViewModel.kt @@ -0,0 +1,241 @@ +package com.arflix.tv.ui.screens.vod + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.arflix.tv.data.model.MediaItem +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.repository.IptvRepository +import com.arflix.tv.data.repository.IptvRepository.IptvVodBrowseCategory +import com.arflix.tv.data.repository.IptvRepository.IptvVodBrowseItem +import com.arflix.tv.data.repository.MediaRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.Locale +import javax.inject.Inject + +/** + * The IPTV VOD catalogue behind the Library's IPTV tab. + * + * The provider's own movie and series lists, in the provider's own categories, + * read through [IptvRepository]'s existing Xtream caches. Browsing lives in + * Library next to the home servers; the TV screen stays live-only. + */ + +/** Virtual categories shown above the provider's own categories. */ +internal const val VOD_CATEGORY_ALL = "__all__" +internal const val VOD_CATEGORY_RECENT = "__recent__" + +data class IptvVodUiState( + val isSeries: Boolean = false, + val isLoading: Boolean = true, + // Only true once a playlist is known to serve VOD, so the tab cannot flash + // in and out while that is being decided. + val hasSource: Boolean = false, + /** False until the catalogue has been read once, so an empty grid is not + * mistaken for "this category has nothing" before the first load runs. */ + val hasLoaded: Boolean = false, + val error: String? = null, + val categories: List = emptyList(), + val categoryCounts: Map = emptyMap(), + val selectedCategoryId: String = VOD_CATEGORY_ALL, + val query: String = "", + val items: List = emptyList(), + val resolvingKey: String? = null, + val message: String? = null +) + +sealed interface IptvVodEvent { + data class OpenDetails(val mediaType: MediaType, val tmdbId: Int) : IptvVodEvent + data class PlayDirect(val mediaId: Int, val streamUrl: String, val title: String) : IptvVodEvent +} + +@HiltViewModel +class IptvVodViewModel @Inject constructor( + private val iptvRepository: IptvRepository, + private val mediaRepository: MediaRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(IptvVodUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() + + private var allItems: List = emptyList() + private var started = false + + init { + // Only the cheap question up front: whether a playlist can serve VOD at + // all, which is what decides if the Library shows the tab. Reading the + // provider's catalogue waits for someone to open it. + viewModelScope.launch { + val hasSource = runCatching { iptvRepository.hasVodBrowseSource() }.getOrDefault(false) + _uiState.value = _uiState.value.copy(hasSource = hasSource, isLoading = false) + } + } + + /** + * Chooses which of the provider's two catalogues to show. The Library calls + * it when the IPTV tab opens, so the first call is also what reads the + * catalogue; returning to an already-loaded tab keeps what is on screen. + */ + fun selectTab(series: Boolean) { + if (!_uiState.value.hasSource) return + if (started && _uiState.value.isSeries == series && !_uiState.value.isLoading) return + _uiState.value = _uiState.value.copy( + isSeries = series, + selectedCategoryId = VOD_CATEGORY_ALL, + query = "" + ) + load() + } + + fun selectCategory(categoryId: String) { + _uiState.value = _uiState.value.copy(selectedCategoryId = categoryId) + applyFilter() + } + + fun setQuery(query: String) { + _uiState.value = _uiState.value.copy(query = query.trim()) + applyFilter() + } + + fun refresh() = load() + + fun dismissMessage() { + _uiState.value = _uiState.value.copy(message = null) + } + + private fun load() { + started = true + val series = _uiState.value.isSeries + _uiState.value = _uiState.value.copy(isLoading = true, error = null, items = emptyList()) + viewModelScope.launch { + val hasSource = runCatching { iptvRepository.hasVodBrowseSource() }.getOrDefault(false) + if (!hasSource) { + allItems = emptyList() + _uiState.value = _uiState.value.copy(isLoading = false, hasSource = false) + return@launch + } + val result = runCatching { iptvRepository.getVodBrowseCatalog(series) } + if (_uiState.value.isSeries != series) return@launch // tab changed while loading + result.onSuccess { catalog -> + allItems = catalog.items + val counts = catalog.items.groupingBy { it.categoryId.orEmpty() }.eachCount() + _uiState.value = _uiState.value.copy( + isLoading = false, + hasLoaded = true, + hasSource = true, + // Hide empty provider categories; they only add noise to the list. + categories = catalog.categories.filter { (counts[it.id] ?: 0) > 0 }, + categoryCounts = counts + ) + applyFilter() + }.onFailure { e -> + allItems = emptyList() + _uiState.value = _uiState.value.copy(isLoading = false, hasLoaded = true, error = e.message ?: "Load failed") + } + } + } + + private fun applyFilter() { + val state = _uiState.value + val query = state.query.lowercase(Locale.ROOT) + val filtered = when (state.selectedCategoryId) { + VOD_CATEGORY_ALL -> allItems + VOD_CATEGORY_RECENT -> allItems.filter { it.addedAt > 0 }.sortedByDescending { it.addedAt }.take(300) + else -> allItems.filter { it.categoryId == state.selectedCategoryId } + }.let { list -> + if (query.isEmpty()) list else list.filter { it.name.lowercase(Locale.ROOT).contains(query) } + } + _uiState.value = state.copy(items = filtered) + } + + /** + * Opens a title. Items the provider tagged with a TMDB id open ARVIO's normal + * Details page (the IPTV copy is found there as a source, next to the addons). + * Untagged items are matched on TMDB by cleaned name + year; movies that still + * can't be matched play straight from the provider. + */ + fun open(item: IptvVodBrowseItem) { + if (_uiState.value.resolvingKey != null) return + val mediaType = if (item.isSeries) MediaType.TV else MediaType.MOVIE + item.tmdbId?.takeIf { it > 0 }?.let { + _events.tryEmit(IptvVodEvent.OpenDetails(mediaType, it)) + return + } + _uiState.value = _uiState.value.copy(resolvingKey = item.key) + viewModelScope.launch { + val matchId = runCatching { findTmdbMatch(item, mediaType) }.getOrNull() + _uiState.value = _uiState.value.copy(resolvingKey = null) + when { + matchId != null -> _events.tryEmit(IptvVodEvent.OpenDetails(mediaType, matchId)) + !item.isSeries && item.streamUrl != null -> _events.tryEmit( + IptvVodEvent.PlayDirect( + mediaId = item.syntheticMediaId(), + streamUrl = item.streamUrl, + title = item.name + ) + ) + // The screen formats this as "could not be matched". + else -> _uiState.value = _uiState.value.copy(message = item.name) + } + } + } + + private suspend fun findTmdbMatch(item: IptvVodBrowseItem, mediaType: MediaType): Int? { + val title = cleanIptvTitle(item.name) + if (title.isBlank()) return null + val candidates = mediaRepository.search(title).filter { it.mediaType == mediaType } + if (candidates.isEmpty()) return null + val year = item.year + if (year != null) { + candidates.firstOrNull { it.year.take(4).toIntOrNull()?.let { y -> kotlin.math.abs(y - year) <= 1 } == true } + ?.let { return it.id } + } + val normalized = title.lowercase(Locale.ROOT) + return candidates.firstOrNull { it.title.lowercase(Locale.ROOT) == normalized }?.id + ?: candidates.first().id.takeIf { year == null } + } +} + +private val LEADING_TAG = Regex("""^\s*(?:[\[|(][^\]|)]{1,12}[\]|)]\s*[-:|]?\s*|[A-Z]{2,4}\s*[-:|]\s+)+""") +private val QUALITY_TAGS = Regex("""(?i)\b(4k|uhd|fhd|hd|sd|1080p|720p|2160p|hevc|x265|x264|multi|sub|dub(?:bed)?)\b""") +private val EMPTY_BRACKETS = Regex("""[(\[]\s*[)\]]""") +private val TRAILING_YEAR = Regex("""[\s._-]*[(\[]?(19|20)\d{2}[)\]]?\s*$""") +private val MULTI_SPACE = Regex("""\s{2,}""") + +/** Strips provider prefixes ("EN - ", "|HE| ", "[4K]"), quality tags and a trailing year. */ +internal fun cleanIptvTitle(raw: String): String = + raw.replace(LEADING_TAG, "") + .replace(QUALITY_TAGS, "") + .replace(EMPTY_BRACKETS, "") + .trim() + .replace(TRAILING_YEAR, "") + .replace(MULTI_SPACE, " ") + .trim(' ', '-', '|', ':', '.') + +/** + * A stable negative id for an item with no TMDB match. It never collides with a + * TMDB id, and the player skips history and scrobbling for ids <= 0. + */ +internal fun IptvVodBrowseItem.syntheticMediaId(): Int = + -((key.hashCode() and 0x7fffffff).coerceAtLeast(1)) + +/** Renders an IPTV title in the Library's normal grid, as a [MediaItem]. */ +internal fun IptvVodBrowseItem.toMediaItem(): MediaItem = MediaItem( + id = tmdbId?.takeIf { it > 0 } ?: syntheticMediaId(), + // The provider's name carries its own prefixes and quality tags; the grid + // shows the cleaned title and the raw name stays the matching input. + title = cleanIptvTitle(name).ifBlank { name }, + year = year?.toString().orEmpty(), + rating = rating.orEmpty(), + mediaType = if (isSeries) MediaType.TV else MediaType.MOVIE, + image = posterUrl.orEmpty() +) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt index 29d2aec6a..ff27eb1ef 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import com.arflix.tv.R import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -51,12 +52,17 @@ import com.arflix.tv.data.model.* import com.arflix.tv.data.repository.* import com.arflix.tv.ui.components.* import com.arflix.tv.ui.skin.resolveAccentColor +import com.arflix.tv.ui.screens.vod.IptvVodEvent +import com.arflix.tv.ui.screens.vod.IptvVodViewModel +import com.arflix.tv.ui.screens.vod.VOD_CATEGORY_ALL +import com.arflix.tv.ui.screens.vod.VOD_CATEGORY_RECENT +import com.arflix.tv.ui.screens.vod.toMediaItem import com.arflix.tv.util.LocalDeviceType import com.arflix.tv.util.tr import com.arflix.tv.ui.screens.watchlist.calendar.* import java.time.LocalDate -internal enum class LibrarySection(val label: String) { WATCHLISTS("Watchlists"), LISTS("My lists"), SERVERS("Homeserver"), CALENDAR("Calendar") } +internal enum class LibrarySection(val label: String) { WATCHLISTS("Watchlists"), LISTS("My lists"), SERVERS("Homeserver"), IPTV("IPTV"), CALENDAR("Calendar") } internal fun libraryColumns(width: Int, poster: Boolean, collections: Boolean = false): Int = if (collections) (width / 270).coerceIn(1, 3) else if (poster) (width / 115).coerceIn(2, 8) else (width / 180).coerceIn(2, 4) internal fun WatchlistSourceItem.isPersonalCollection(): Boolean = this is WatchlistSourceItem.Catalog || @@ -66,7 +72,8 @@ internal fun librarySources(sources: List, section: Library LibrarySection.WATCHLISTS -> it is WatchlistSourceItem.MyWatchlist || (it is WatchlistSourceItem.TrackerList && !it.isPersonalCollection()) LibrarySection.LISTS -> it.isPersonalCollection() LibrarySection.SERVERS -> it is WatchlistSourceItem.HomeServer - LibrarySection.CALENDAR -> false + // Both build their own source list rather than filtering the saved ones. + LibrarySection.IPTV, LibrarySection.CALENDAR -> false } } @OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class) @@ -75,6 +82,8 @@ fun WatchlistScreen( viewModel: WatchlistViewModel = hiltViewModel(), currentProfile: Profile? = null, onNavigateToDetails: (MediaType, Int) -> Unit = { _, _ -> }, + /** An IPTV movie with no TMDB match plays straight from the provider. */ + onPlayIptvVod: (mediaId: Int, streamUrl: String, title: String) -> Unit = { _, _, _ -> }, onNavigateToHome: () -> Unit = {}, onNavigateToSearch: () -> Unit = {}, onNavigateToTv: () -> Unit = {}, onNavigateToSettings: (String?) -> Unit = {}, onSwitchProfile: () -> Unit = {}, onBack: () -> Unit = {}, @@ -83,10 +92,12 @@ fun WatchlistScreen( onCalendarChangeMonth: (Long) -> Unit = {}, onCalendarSelectSource: (String) -> Unit = {}, onCalendarRefresh: () -> Unit = {}, - calendarViewModel: ReleaseCalendarViewModel? = null + calendarViewModel: ReleaseCalendarViewModel? = null, + vodViewModel: IptvVodViewModel = hiltViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val servers by viewModel.libraryState.collectAsStateWithLifecycle() + val vod by vodViewModel.uiState.collectAsStateWithLifecycle() val logos by viewModel.logoUrls.collectAsStateWithLifecycle() val touch = LocalDeviceType.current.isTouchDevice() val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl @@ -94,6 +105,11 @@ fun WatchlistScreen( val scrollScope = rememberCoroutineScope() var section by rememberSaveable { mutableStateOf(LibrarySection.WATCHLISTS) } val calendarMode = section == LibrarySection.CALENDAR + val iptvMode = section == LibrarySection.IPTV + // The tab only exists once a playlist can actually serve a VOD catalogue. + val sections = remember(vod.hasSource) { + LibrarySection.entries.filter { it != LibrarySection.IPTV || vod.hasSource } + } val calendarTab = remember { FocusRequester() } var openedList by rememberSaveable { mutableStateOf(null) } var query by rememberSaveable { mutableStateOf("") } @@ -108,21 +124,40 @@ fun WatchlistScreen( var initialFocusPlaced by remember { mutableStateOf(false) } val filterButton = remember { FocusRequester() } val topFocus = remember { FocusRequester() } - val scopeSources = remember(state.sources, servers.libraries, section) { if(section == LibrarySection.SERVERS) servers.libraries.map { WatchlistSourceItem.HomeServer(it) } else librarySources(state.sources, section) } + val scopeSources = remember(state.sources, servers.libraries, section, vod.categories) { when (section) { + LibrarySection.SERVERS -> servers.libraries.map { WatchlistSourceItem.HomeServer(it) } + // "All" and "Recently added" sit above the provider's own categories. + LibrarySection.IPTV -> listOf( + WatchlistSourceItem.IptvCategory(VOD_CATEGORY_ALL, "All"), + WatchlistSourceItem.IptvCategory(VOD_CATEGORY_RECENT, "Recently added") + ) + vod.categories.map { WatchlistSourceItem.IptvCategory(it.id, it.name) } + else -> librarySources(state.sources, section) + } } val collections = section == LibrarySection.LISTS && openedList == null val serverMode = section == LibrarySection.SERVERS - val selectedId = if (serverMode) "server_${servers.selectedSourceRef}" else state.selectedSourceId + val selectedId = when { + iptvMode -> "iptv_${vod.selectedCategoryId}" + serverMode -> "server_${servers.selectedSourceRef}" + else -> state.selectedSourceId + } val savedItems = remember(state.movies, state.series, state.selectedSourceId) { val combined = state.movies + state.series if(state.selectedSourceId == WatchlistSourceItem.MyWatchlist.id) combined.sortedByDescending { it.addedAt } else combined } - val rawItems = if (serverMode) servers.items else savedItems - val items = remember(rawItems, query, sort, mediaFilter, serverMode) { - val filtered = rawItems.filter { (mediaFilter == null || it.mediaType == mediaFilter) && (serverMode || it.title.contains(query, true)) } - if (serverMode) filtered else sortLibraryItems(filtered, sort) + // The IPTV catalogue is already filtered by category and query in its own + // view model, so the grid shows it as it comes. + val vodItems = remember(vod.items) { vod.items.map { it.toMediaItem() } } + val rawItems = when { iptvMode -> vodItems; serverMode -> servers.items; else -> savedItems } + val items = remember(rawItems, query, sort, mediaFilter, serverMode, iptvMode) { + if (iptvMode) rawItems else { + val filtered = rawItems.filter { (mediaFilter == null || it.mediaType == mediaFilter) && (serverMode || it.title.contains(query, true)) } + if (serverMode) filtered else sortLibraryItems(filtered, sort) + } } - val loading = if (serverMode) servers.isLoading else state.isLoading - val error = if (serverMode) servers.error else state.error + // Until the first read finishes the tab is loading, not empty — the read + // starts from an effect, so the first frame would otherwise say "no titles". + val loading = when { iptvMode -> vod.isLoading || !vod.hasLoaded; serverMode -> servers.isLoading; else -> state.isLoading } + val error = when { iptvMode -> vod.error; serverMode -> servers.error; else -> state.error } val sourceKey = if (collections) "collections" else "$section:$selectedId:$query:$sort:$mediaFilter" val viewports = rememberSaveable(saver = mapSaver( save = { states: MutableMap -> states.mapValues { (_, grid) -> arrayListOf(grid.firstVisibleItemIndex, grid.firstVisibleItemScrollOffset) } }, @@ -130,14 +165,21 @@ fun WatchlistScreen( )) { mutableMapOf() } val grid = remember(sourceKey) { viewports.getOrPut(sourceKey) { LazyGridState() } } fun selectSource(source: WatchlistSourceItem) { - if (source is WatchlistSourceItem.HomeServer) { - viewModel.selectLibraryProvider(source.candidate.serverKind) - viewModel.selectLibrary(source.candidate.sourceRef) - } else viewModel.selectSource(source.id) + when (source) { + is WatchlistSourceItem.HomeServer -> { + viewModel.selectLibraryProvider(source.candidate.serverKind) + viewModel.selectLibrary(source.candidate.sourceRef) + } + is WatchlistSourceItem.IptvCategory -> vodViewModel.selectCategory(source.categoryId) + else -> viewModel.selectSource(source.id) + } sourcesOpen = false } fun selectSection(next: LibrarySection) { - section = next; openedList = null; query = ""; mediaFilter = null + section = next; openedList = null; query = "" + // In the IPTV tab the type is not a filter over one list but a choice of + // which of the provider's two catalogues to read, so it starts on movies. + mediaFilter = if (next == LibrarySection.IPTV) MediaType.MOVIE else null if (next == LibrarySection.WATCHLISTS) viewModel.selectSource(WatchlistSourceItem.MyWatchlist.id) if (next == LibrarySection.SERVERS && servers.selectedSourceRef == null) { servers.libraries.firstOrNull()?.let { viewModel.selectLibraryProvider(it.serverKind); viewModel.selectLibrary(it.sourceRef) } @@ -159,17 +201,30 @@ fun WatchlistScreen( } } LaunchedEffect(serverMode, sort) { if (serverMode) viewModel.setLibrarySort(sort) } + // A playlist removed while the tab is open takes the tab with it. + LaunchedEffect(vod.hasSource) { if (!vod.hasSource && section == LibrarySection.IPTV) selectSection(LibrarySection.WATCHLISTS) } + LaunchedEffect(iptvMode, query) { if (iptvMode) vodViewModel.setQuery(query) } + LaunchedEffect(iptvMode, mediaFilter) { if (iptvMode) vodViewModel.selectTab(mediaFilter == MediaType.TV) } + LaunchedEffect(vodViewModel) { + vodViewModel.events.collect { event -> + when (event) { + is IptvVodEvent.OpenDetails -> onNavigateToDetails(event.mediaType, event.tmdbId) + is IptvVodEvent.PlayDirect -> onPlayIptvVod(event.mediaId, event.streamUrl, event.title) + } + } + } val loadingMore = if(serverMode) servers.isLoadingMore else state.isLoadingMore - val hasMore = if(serverMode) servers.hasMore else state.hasMore + // The provider hands over its whole catalogue at once; there is no next page. + val hasMore = !iptvMode && (if(serverMode) servers.hasMore else state.hasMore) LaunchedEffect(grid, sourceKey, items.size, rawItems.size, error, loading, loadingMore, hasMore) { snapshotFlow { grid.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 }.collect { last -> - if (!calendarMode && !collections && error == null && !loading && !loadingMore && hasMore && (items.isEmpty() || last >= items.size - 16)) { + if (!calendarMode && !iptvMode && !collections && error == null && !loading && !loadingMore && hasMore && (items.isEmpty() || last >= items.size - 16)) { if (serverMode) viewModel.loadMoreLibrary() else viewModel.loadMoreActiveSource() } } } LaunchedEffect(grid, sourceKey, items, poster) { - if (!calendarMode && !poster && !collections) snapshotFlow { grid.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }.collect { last -> + if (!calendarMode && !iptvMode && !poster && !collections) snapshotFlow { grid.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }.collect { last -> val first = grid.firstVisibleItemIndex viewModel.prefetchLogos(items.subList(first.coerceAtMost(items.size), (last + 17).coerceAtMost(items.size))) } @@ -211,7 +266,7 @@ fun WatchlistScreen( Row(Modifier.fillMaxWidth().testTag("library-section-tabs").then(if (compact) Modifier.horizontalScroll(rememberScrollState()) else Modifier) .padding(bottom = if(compact) 10.dp else if(calendarMode) 0.dp else 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 8.dp)) { - LibrarySection.entries.forEachIndexed { index, entry -> + sections.forEachIndexed { index, entry -> OledControl(tr(entry.label), selected = section == entry, calendarTabStyle = calendarMode, modifier = (if(index == 0) Modifier.focusRequester(firstTab).onGloballyPositioned { if(!touch && !initialFocusPlaced) { initialFocusPlaced = true; firstTab.requestFocus() } @@ -271,22 +326,29 @@ fun WatchlistScreen( CircularProgressIndicator(Modifier.align(Alignment.Center), color = resolveAccentColor(Color.White)) } else if (items.isEmpty()) { Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) { - OledMessage(if(error != null) tr("Library unavailable") else tr("No titles found"), error ?: tr("Choose a source or add titles to your watchlist.")) - if(error != null) OledControl(tr("Retry"), onClick = { if(serverMode) viewModel.refreshLibrary() else viewModel.refresh() }) + OledMessage( + if(error != null) tr("Library unavailable") else tr("No titles found"), + error ?: if(iptvMode) tr("Your IPTV provider returned no titles for this category.") + else tr("Choose a source or add titles to your watchlist.") + ) + if(error != null) OledControl(tr("Retry"), onClick = { when { iptvMode -> vodViewModel.refresh(); serverMode -> viewModel.refreshLibrary(); else -> viewModel.refresh() } }) } } else LazyVerticalGrid(GridCells.Fixed(columns), state = grid, modifier = Modifier.fillMaxSize().testTag("library-grid"), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(18.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp + LocalBottomBarInset.current)) { itemsIndexed(items, key = { index, item -> watchlistItemKey(item, index) }) { index, item -> val reveal = remember { BringIntoViewRequester() } - LaunchedEffect(watchlistLogoKey(item), poster) { if(!poster) viewModel.ensureLogo(item) } + LaunchedEffect(watchlistLogoKey(item), poster) { if(!poster && !iptvMode) viewModel.ensureLogo(item) } Box(Modifier.bringIntoViewRequester(reveal).padding(6.dp).testTag("library-card-frame-$index")) { MediaCard(item, width = width - 12.dp, isLandscape = !poster, logoImageUrl = logos[watchlistLogoKey(item)], focusedScale = 1.025f, titleMaxLines = 1, showTitle = true, modifier = Modifier.testTag("library-card-$index"), onFocused = { viewModel.saveFocusState(0, index); if(!touch) scrollScope.launch { reveal.bringIntoView() } }, - onClick = { onNavigateToDetails(item.mediaType, item.id) }, - onLongClick = if(state.selectedSourceId == WatchlistSourceItem.MyWatchlist.id && !serverMode) ({ viewModel.removeFromWatchlist(item) }) else null) + onClick = { + if (iptvMode) vod.items.getOrNull(index)?.let(vodViewModel::open) + else onNavigateToDetails(item.mediaType, item.id) + }, + onLongClick = if(state.selectedSourceId == WatchlistSourceItem.MyWatchlist.id && !serverMode && !iptvMode) ({ viewModel.removeFromWatchlist(item) }) else null) } } if (if(serverMode) servers.isLoadingMore else state.isLoadingMore) item("loading-more", span = { GridItemSpan(maxLineSpan) }) { @@ -295,7 +357,7 @@ fun WatchlistScreen( } } if(error != null) item("retry-page", span = { GridItemSpan(maxLineSpan) }) { - OledControl(tr("Could not update this source. Retry"), onClick = { if(serverMode) viewModel.refreshLibrary() else viewModel.refresh() }) + OledControl(tr("Could not update this source. Retry"), onClick = { when { iptvMode -> vodViewModel.refresh(); serverMode -> viewModel.refreshLibrary(); else -> viewModel.refresh() } }) } } } @@ -305,23 +367,41 @@ fun WatchlistScreen( state.toastMessage?.let { message -> Toast(message = message, isVisible = true, onDismiss = viewModel::dismissToast) } + if (vod.resolvingKey != null) { + // An untagged title costs a TMDB lookup before anything opens, so the + // tap is acknowledged instead of the screen sitting there unchanged. + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f)), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = resolveAccentColor(Color.White)) + } + } + vod.message?.let { title -> + // The provider listed it, but nothing on TMDB matched and it has no + // direct stream, so say which title rather than failing silently. + Toast(message = stringResource(R.string.vod_not_matched, title), isVisible = true, onDismiss = vodViewModel::dismissMessage) + } } if(sourcesOpen) OledDrawer(tr("Sources"), { sourcesOpen = false }) { OledSources(scopeSources, selectedId, Modifier.fillMaxWidth().heightIn(max = 500.dp), ::selectSource, onNavigateToSettings) } if(filters) OledDrawer(tr("Filters"), { filters = false; if(!touch) filterButton.requestFocus() }) { - Text(tr("Sort"), color = Color.LightGray, modifier = Modifier.padding(vertical = 12.dp)) - listOf("Recently added" to HomeServerLibrarySort.RECENTLY_ADDED, "Title A-Z" to HomeServerLibrarySort.TITLE, - "Highest rated" to HomeServerLibrarySort.RATING, "Newest release" to HomeServerLibrarySort.RELEASE_DATE_NEWEST, - "Oldest release" to HomeServerLibrarySort.RELEASE_DATE_OLDEST).forEach { (label, value) -> - OledControl(tr(label), selected = sort == value, modifier = Modifier.fillMaxWidth(), onClick = { sort = value }) + if(!iptvMode) { + Text(tr("Sort"), color = Color.LightGray, modifier = Modifier.padding(vertical = 12.dp)) + listOf("Recently added" to HomeServerLibrarySort.RECENTLY_ADDED, "Title A-Z" to HomeServerLibrarySort.TITLE, + "Highest rated" to HomeServerLibrarySort.RATING, "Newest release" to HomeServerLibrarySort.RELEASE_DATE_NEWEST, + "Oldest release" to HomeServerLibrarySort.RELEASE_DATE_OLDEST).forEach { (label, value) -> + OledControl(tr(label), selected = sort == value, modifier = Modifier.fillMaxWidth(), onClick = { sort = value }) + } } if(!collections) { Text(tr("Type"), color = Color.LightGray, modifier = Modifier.padding(vertical = 12.dp)) - listOf("All" to null, "Movies" to MediaType.MOVIE, "Series" to MediaType.TV).forEach { (label, value) -> + // The provider keeps movies and series in separate catalogues, so the + // IPTV tab picks one instead of offering a combined list. + val types = if(iptvMode) listOf("Movies" to MediaType.MOVIE, "Series" to MediaType.TV) + else listOf("All" to null, "Movies" to MediaType.MOVIE, "Series" to MediaType.TV) + types.forEach { (label, value) -> OledControl(tr(label), selected = mediaFilter == value, modifier = Modifier.fillMaxWidth(), onClick = { mediaFilter = value }) } - OledControl(tr("Refresh"), onClick = { if(serverMode) viewModel.refreshLibrary() else viewModel.refresh() }) + OledControl(tr("Refresh"), onClick = { when { iptvMode -> vodViewModel.refresh(); serverMode -> viewModel.refreshLibrary(); else -> viewModel.refresh() } }) } } TextInputModal(search, tr("Search library"), initialValue = query, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt index 4301a5e1b..24b0e07b9 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt @@ -107,6 +107,19 @@ sealed interface WatchlistSourceItem { } } + /** + * One category of the IPTV provider's VOD catalogue, listed in the Library's + * IPTV tab the same way a home server's libraries are. + */ + data class IptvCategory( + val categoryId: String, + override val title: String, + override val subtitle: String? = null + ) : WatchlistSourceItem { + override val id: String = "iptv_$categoryId" + override val displayLabel: String = title + } + data class TrackerList( val provider: TrackerLibraryProvider, val listKey: String, @@ -847,6 +860,9 @@ class WatchlistViewModel @Inject constructor( } } } + // The IPTV tab reads the provider catalogue through its own view + // model; this one never holds its items. + is WatchlistSourceItem.IptvCategory -> Unit } } diff --git a/app/src/main/kotlin/com/arflix/tv/util/AppLanguage.kt b/app/src/main/kotlin/com/arflix/tv/util/AppLanguage.kt index c697c6115..fceda9e34 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/AppLanguage.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/AppLanguage.kt @@ -90,6 +90,8 @@ fun tr(text: String): String { if (text.isBlank()) return text @StringRes val resId: Int? = when (text.trim()) { "Homeserver" -> R.string.library_homeserver_tab + "IPTV" -> R.string.library_iptv_tab + "Your IPTV provider returned no titles for this category." -> R.string.library_iptv_empty "Calendar" -> R.string.library_calendar "All watchlists" -> R.string.calendar_all_watchlists "ARVIO watchlist" -> R.string.calendar_arvio_watchlist diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index dca02db3e..e0b9b9bd9 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -295,4 +295,7 @@ הוספת התוסף נכשלה קטלוג TMDB השתמש בכתובת של דף TMDB מסוג list, collection, company, network, person, keyword או genre + לא ניתן לפתוח את \"%1$s\" + IPTV + ספק ה-IPTV לא החזיר כותרים בקטגוריה זו. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8022b4237..43488c182 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1883,5 +1883,8 @@ Connect SIMKL SIMKL connected Open SIMKL + Could not open \"%1$s\" + IPTV + Your IPTV provider returned no titles for this category. diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/vod/CleanIptvTitleTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/vod/CleanIptvTitleTest.kt new file mode 100644 index 000000000..319a96114 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/vod/CleanIptvTitleTest.kt @@ -0,0 +1,27 @@ +package com.arflix.tv.ui.screens.vod + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CleanIptvTitleTest { + + @Test + fun `strips provider language prefixes`() { + assertEquals("The Matrix", cleanIptvTitle("EN - The Matrix")) + assertEquals("Fauda", cleanIptvTitle("|HE| Fauda")) + assertEquals("Dune", cleanIptvTitle("[EN] Dune")) + } + + @Test + fun `strips trailing year and quality tags`() { + assertEquals("Inception", cleanIptvTitle("Inception (2010)")) + assertEquals("Inception", cleanIptvTitle("Inception (2010) [4K]")) + assertEquals("Oppenheimer", cleanIptvTitle("EN - Oppenheimer 2023 FHD")) + } + + @Test + fun `keeps titles that contain numbers`() { + assertEquals("Blade Runner 2049", cleanIptvTitle("Blade Runner 2049 (2017)")) + assertEquals("Ocean's Eleven", cleanIptvTitle("Ocean's Eleven")) + } +}