From b137ca56dc7be29fd5806143465bec11ed1f00b5 Mon Sep 17 00:00:00 2001 From: koby455 Date: Sat, 19 Sep 2026 23:39:37 +0300 Subject: [PATCH 1/3] Add an IPTV VOD browser next to TV in the top bar IPTV VOD was only used behind the scenes, as an extra source when a TMDB title was opened. There was no way to browse the provider's own movie and series catalogue. - New "VOD" top-bar entry (and mobile bottom-bar tab) next to TV, opening an IPTV VOD screen: Movies / Series tabs, the provider's own categories with counts, "Recently added", search, and a poster grid. - Selecting a title opens ARVIO's normal Details page when the provider tags a TMDB id, otherwise the cleaned name + year is matched on TMDB. Movies that still cannot be matched play directly from the provider. - Xtream VOD/series wire models now keep artwork, rating and added date; disk caches are versioned (v2) so they are refetched once with artwork. - The player skips scrobbling and watch history for synthetic ids (<= 0) used by the direct-play fallback. - English and Hebrew strings. Co-Authored-By: Claude Opus 5 --- .../tv/data/repository/IptvRepository.kt | 107 ++++- .../com/arflix/tv/navigation/AppNavigation.kt | 42 ++ .../arflix/tv/ui/components/AppBottomBar.kt | 3 + .../com/arflix/tv/ui/components/Sidebar.kt | 3 + .../tv/ui/screens/details/DetailsScreen.kt | 2 + .../arflix/tv/ui/screens/home/HomeScreen.kt | 4 + .../tv/ui/screens/player/PlayerViewModel.kt | 2 +- .../tv/ui/screens/search/SearchScreen.kt | 3 +- .../tv/ui/screens/settings/SettingsScreen.kt | 2 + .../com/arflix/tv/ui/screens/tv/TvScreen.kt | 2 + .../tv/ui/screens/tv/live/LiveTvScreen.kt | 2 + .../arflix/tv/ui/screens/vod/IptvVodScreen.kt | 377 ++++++++++++++++++ .../tv/ui/screens/vod/IptvVodViewModel.kt | 195 +++++++++ app/src/main/res/values-iw/strings.xml | 16 + app/src/main/res/values/strings.xml | 16 + .../tv/ui/screens/vod/CleanIptvTitleTest.kt | 27 ++ 16 files changed, 797 insertions(+), 6 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/vod/IptvVodScreen.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/vod/IptvVodViewModel.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/ui/screens/vod/CleanIptvTitleTest.kt 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..efc7807a1 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,8 @@ 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.ui.screens.vod.IptvVodScreen +import com.arflix.tv.data.model.IptvVodSourceIds import com.arflix.tv.ui.screens.profile.ProfileSelectionScreen import com.arflix.tv.util.LocalDeviceType @@ -44,6 +46,7 @@ sealed class Screen(val route: String) { data object Home : Screen("home") data object Search : Screen("search") data object Watchlist : Screen("watchlist") + data object Vod : Screen("vod") data object CollectionDetails : Screen("collections/{catalogId}") { fun createRoute(catalogId: String): String { return "collections/${android.net.Uri.encode(catalogId)}" @@ -230,6 +233,7 @@ fun AppNavigation( onNavigateToWatchlist = { navigateTopLevel(Screen.Watchlist.route) }, + onNavigateToVod = { navigateTopLevel(Screen.Vod.route) }, onNavigateToTv = { channelId, streamUrl -> navigateTopLevel(Screen.Tv.createRoute(channelId, streamUrl)) }, @@ -265,6 +269,7 @@ fun AppNavigation( }, onNavigateToHome = { navigateHome() }, onNavigateToWatchlist = { navigateTopLevel(Screen.Watchlist.route) }, + onNavigateToVod = { navigateTopLevel(Screen.Vod.route) }, onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) }, onNavigateToSettings = { navigateTopLevel(Screen.Settings.route) }, onSwitchProfile = { @@ -286,6 +291,40 @@ fun AppNavigation( }, onNavigateToHome = { navigateHome() }, onNavigateToSearch = { navigateTopLevel(Screen.Search.route) }, + onNavigateToVod = { navigateTopLevel(Screen.Vod.route) }, + onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) }, + onNavigateToSettings = { section -> + navigateTopLevel(Screen.Settings.createRoute(initialSection = section)) + }, + onSwitchProfile = { + onSwitchProfile() + navController.navigateToProfileSelection() + }, + onBack = { navigateHome() } + ) + } + + // IPTV VOD catalogue (top-bar entry next to TV) + composable(Screen.Vod.route) { + IptvVodScreen( + currentProfile = currentProfile, + onNavigateToDetails = { mediaType, mediaId -> + navController.navigate(Screen.Details.createRoute(mediaType, mediaId)) + }, + onPlayDirect = { 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) }, + onNavigateToWatchlist = { navigateTopLevel(Screen.Watchlist.route) }, onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) }, onNavigateToSettings = { section -> navigateTopLevel(Screen.Settings.createRoute(initialSection = section)) @@ -317,6 +356,7 @@ fun AppNavigation( onNavigateToHome = { navigateHome() }, onNavigateToSearch = { navigateTopLevel(Screen.Search.route) }, onNavigateToWatchlist = { navigateTopLevel(Screen.Watchlist.route) }, + onNavigateToVod = { navigateTopLevel(Screen.Vod.route) }, onNavigateToSettings = { navigateTopLevel(Screen.Settings.route) }, onNavigateToIptvSettings = { navigateTopLevel(Screen.Settings.createRoute(initialSection = "iptv")) }, onNavigateToDetails = { mediaType, mediaId -> @@ -367,6 +407,7 @@ fun AppNavigation( installAddonUrl = installAddonUrl, onNavigateToHome = { navigateHome() }, onNavigateToSearch = { navigateTopLevel(Screen.Search.route) }, + onNavigateToVod = { navigateTopLevel(Screen.Vod.route) }, onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) }, onNavigateToWatchlist = { navigateTopLevel(Screen.Watchlist.route) }, onNavigateToTelegramSettings = { navController.navigate(Screen.TelegramSettings.route) }, @@ -527,6 +568,7 @@ fun AppNavigation( onNavigateToSearch = { navigateTopLevel(Screen.Search.route) }, + onNavigateToVod = { navigateTopLevel(Screen.Vod.route) }, onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) }, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt index b86ef1187..70c6168bb 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt @@ -36,6 +36,7 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.LiveTv import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -148,6 +149,7 @@ internal fun shouldShowBottomBar( route == Screen.Search.route -> true route == Screen.Watchlist.route -> true route == "tv" -> true + route == "vod" -> true route == "settings" -> true else -> false } @@ -164,6 +166,7 @@ val bottomBarItems = listOf( BottomBarItem(R.string.search, Icons.Default.Search, "search"), BottomBarItem(R.string.nav_library, Icons.Default.Bookmark, "watchlist"), BottomBarItem(R.string.topbar_tv, Icons.Default.LiveTv, "tv"), + BottomBarItem(R.string.nav_vod, Icons.Default.VideoLibrary, "vod"), BottomBarItem(R.string.settings, Icons.Default.Settings, "settings") ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt index 059ba8a7e..4b717d85b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt @@ -28,6 +28,7 @@ import androidx.compose.material.icons.outlined.Bookmark import androidx.compose.material.icons.outlined.LiveTv import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.VideoLibrary import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -62,6 +63,8 @@ enum class SidebarItem(val icon: ImageVector, @StringRes val labelRes: Int) { HOME(Icons.Outlined.Home, R.string.home), WATCHLIST(Icons.Outlined.Bookmark, R.string.nav_library), TV(Icons.Outlined.LiveTv, R.string.tv_shows), + // IPTV provider VOD catalogue, right next to TV. + VOD(Icons.Outlined.VideoLibrary, R.string.nav_vod), SETTINGS(Icons.Outlined.Settings, R.string.settings) } 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..74a5642d3 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 @@ -249,6 +249,7 @@ fun DetailsScreen( onNavigateToSearch: () -> Unit = {}, onNavigateToWatchlist: () -> Unit = {}, onNavigateToTv: () -> Unit = {}, + onNavigateToVod: () -> Unit = {}, onNavigateToSettings: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, onFullscreenChanged: (Boolean) -> Unit = {}, @@ -774,6 +775,7 @@ fun DetailsScreen( SidebarItem.HOME -> onNavigateToHome() SidebarItem.WATCHLIST -> onNavigateToWatchlist() SidebarItem.TV -> onNavigateToTv() + SidebarItem.VOD -> onNavigateToVod() SidebarItem.SETTINGS -> onNavigateToSettings() null -> Unit } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index c40ee3956..116383257 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -720,6 +720,7 @@ fun HomeScreen( onNavigateToSearch: () -> Unit = {}, onNavigateToWatchlist: () -> Unit = {}, onNavigateToTv: (channelId: String?, streamUrl: String?) -> Unit = { _, _ -> }, + onNavigateToVod: () -> Unit = {}, onNavigateToPlayer: (MediaType, Int, String, String?, String?) -> Unit = { _, _, _, _, _ -> }, onNavigateToSettings: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, @@ -1355,6 +1356,7 @@ fun HomeScreen( onNavigateToSearch = onNavigateToSearch, onNavigateToWatchlist = onNavigateToWatchlist, onNavigateToTv = onNavigateToTv, + onNavigateToVod = onNavigateToVod, isSportsHomeItem = { item -> viewModel.isSportsHomeItem(item) }, onSportsHomeItemClick = openSportsHomeItem, onNavigateToSettings = onNavigateToSettings, @@ -2482,6 +2484,7 @@ internal fun HomeInputLayer( onNavigateToSearch: () -> Unit, onNavigateToWatchlist: () -> Unit, onNavigateToTv: (channelId: String?, streamUrl: String?) -> Unit, + onNavigateToVod: () -> Unit = {}, isSportsHomeItem: (MediaItem) -> Boolean = { false }, onSportsHomeItemClick: (MediaItem) -> Unit = {}, onNavigateToSettings: () -> Unit, @@ -2639,6 +2642,7 @@ internal fun HomeInputLayer( SidebarItem.HOME -> Unit SidebarItem.WATCHLIST -> onNavigateToWatchlist() SidebarItem.TV -> onNavigateToTv(null, null) + SidebarItem.VOD -> onNavigateToVod() SidebarItem.SETTINGS -> onNavigateToSettings() null -> Unit } 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/search/SearchScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt index 8d0c10aed..2818e6328 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt @@ -151,6 +151,7 @@ fun SearchScreen( onNavigateToHome: () -> Unit = {}, onNavigateToWatchlist: () -> Unit = {}, onNavigateToTv: () -> Unit = {}, + onNavigateToVod: () -> Unit = {}, onNavigateToSettings: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, onBack: () -> Unit = {} @@ -696,7 +697,7 @@ fun SearchScreen( FocusZone.PANEL -> false FocusZone.SIDEBAR -> { if (hasProfile && sidebarFocusIndex == 0) onSwitchProfile() - else when (topBarFocusedItem(sidebarFocusIndex, hasProfile)) { SidebarItem.SEARCH -> Unit; SidebarItem.HOME -> onNavigateToHome(); SidebarItem.WATCHLIST -> onNavigateToWatchlist(); SidebarItem.TV -> onNavigateToTv(); SidebarItem.SETTINGS -> onNavigateToSettings(); null -> Unit } + else when (topBarFocusedItem(sidebarFocusIndex, hasProfile)) { SidebarItem.SEARCH -> Unit; SidebarItem.HOME -> onNavigateToHome(); SidebarItem.WATCHLIST -> onNavigateToWatchlist(); SidebarItem.TV -> onNavigateToTv(); SidebarItem.VOD -> onNavigateToVod(); SidebarItem.SETTINGS -> onNavigateToSettings(); null -> Unit } true } FocusZone.SEARCH_INPUT -> { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index ef71275b4..61032ef97 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -543,6 +543,7 @@ fun SettingsScreen( onNavigateToHome: () -> Unit = {}, onNavigateToSearch: () -> Unit = {}, onNavigateToTv: () -> Unit = {}, + onNavigateToVod: () -> Unit = {}, onNavigateToWatchlist: () -> Unit = {}, onNavigateToTelegramSettings: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, @@ -1406,6 +1407,7 @@ fun SettingsScreen( SidebarItem.SEARCH -> onNavigateToSearch() SidebarItem.HOME -> onNavigateToHome() SidebarItem.TV -> onNavigateToTv() + SidebarItem.VOD -> onNavigateToVod() SidebarItem.WATCHLIST -> onNavigateToWatchlist() SidebarItem.SETTINGS -> { /* Already here */ } null -> Unit diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt index 9ad6db0a1..77487bd6a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt @@ -271,6 +271,7 @@ fun TvScreen( onNavigateToHome: () -> Unit = {}, onNavigateToSearch: () -> Unit = {}, onNavigateToWatchlist: () -> Unit = {}, + onNavigateToVod: () -> Unit = {}, onNavigateToSettings: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, onBack: () -> Unit = {} @@ -920,6 +921,7 @@ fun TvScreen( SidebarItem.HOME -> onNavigateToHome() SidebarItem.WATCHLIST -> onNavigateToWatchlist() SidebarItem.TV -> Unit + SidebarItem.VOD -> onNavigateToVod() SidebarItem.SETTINGS -> onNavigateToSettings() null -> Unit } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt index 767be638d..3caf5f527 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt @@ -549,6 +549,7 @@ fun LiveTvScreen( onNavigateToHome: () -> Unit = {}, onNavigateToSearch: () -> Unit = {}, onNavigateToWatchlist: () -> Unit = {}, + onNavigateToVod: () -> Unit = {}, onNavigateToSettings: () -> Unit = {}, onNavigateToIptvSettings: (() -> Unit)? = null, onNavigateToDetails: (com.arflix.tv.data.model.MediaType, Int) -> Unit = { _, _ -> }, @@ -3873,6 +3874,7 @@ fun LiveTvScreen( SidebarItem.HOME -> onNavigateToHome() SidebarItem.WATCHLIST -> onNavigateToWatchlist() SidebarItem.TV -> Unit + SidebarItem.VOD -> onNavigateToVod() SidebarItem.SETTINGS -> onNavigateToSettings() null -> Unit } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/vod/IptvVodScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/vod/IptvVodScreen.kt new file mode 100644 index 000000000..5853f7756 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/vod/IptvVodScreen.kt @@ -0,0 +1,377 @@ +package com.arflix.tv.ui.screens.vod + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.key.* +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import com.arflix.tv.R +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.model.Profile +import com.arflix.tv.data.repository.IptvRepository.IptvVodBrowseItem +import com.arflix.tv.ui.components.AppTopBar +import com.arflix.tv.ui.components.LocalBottomBarInset +import com.arflix.tv.ui.components.SidebarItem +import com.arflix.tv.ui.components.TextInputModal +import com.arflix.tv.ui.components.Toast +import com.arflix.tv.ui.components.ToastType +import com.arflix.tv.ui.components.topBarFocusedItem +import com.arflix.tv.ui.components.topBarMaxIndex +import com.arflix.tv.ui.components.topBarSelectedIndex +import com.arflix.tv.ui.screens.watchlist.OledControl +import com.arflix.tv.ui.skin.resolveAccentColor +import com.arflix.tv.util.LocalDeviceType + +/** + * Browses the IPTV provider's own VOD catalogue (movies / series in the provider's + * categories). Reached from the "VOD" entry next to TV in the top bar. + */ +@Composable +fun IptvVodScreen( + viewModel: IptvVodViewModel = hiltViewModel(), + currentProfile: Profile? = null, + onNavigateToDetails: (MediaType, Int) -> Unit = { _, _ -> }, + onPlayDirect: (mediaId: Int, streamUrl: String, title: String) -> Unit = { _, _, _ -> }, + onNavigateToHome: () -> Unit = {}, + onNavigateToSearch: () -> Unit = {}, + onNavigateToWatchlist: () -> Unit = {}, + onNavigateToTv: () -> Unit = {}, + onNavigateToSettings: (String) -> Unit = {}, + onSwitchProfile: () -> Unit = {}, + onBack: () -> Unit = {} +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val touch = LocalDeviceType.current.isTouchDevice() + val hasProfile = currentProfile != null + var topFocused by remember { mutableStateOf(false) } + var topIndex by remember { mutableIntStateOf(topBarSelectedIndex(SidebarItem.VOD, hasProfile).coerceAtLeast(0)) } + val topFocus = remember { FocusRequester() } + val firstTab = remember { FocusRequester() } + var initialFocusPlaced by remember { mutableStateOf(false) } + var searchOpen by remember { mutableStateOf(false) } + val grid = rememberLazyGridState() + + LaunchedEffect(viewModel) { + viewModel.events.collect { event -> + when (event) { + is IptvVodEvent.OpenDetails -> onNavigateToDetails(event.mediaType, event.tmdbId) + is IptvVodEvent.PlayDirect -> onPlayDirect(event.mediaId, event.streamUrl, event.title) + } + } + } + LaunchedEffect(state.selectedCategoryId, state.isSeries, state.query) { grid.scrollToItem(0) } + + BackHandler(!searchOpen) { + when { + state.query.isNotEmpty() -> viewModel.setQuery("") + !touch && !topFocused -> topFocus.requestFocus() + else -> onBack() + } + } + + val allLabel = stringResource(R.string.vod_all) + val recentLabel = stringResource(R.string.vod_recent) + val categoryEntries = remember(state.categories, state.categoryCounts, allLabel, recentLabel) { + buildList { + add(Triple(VOD_CATEGORY_ALL, allLabel, state.categoryCounts.values.sum())) + add(Triple(VOD_CATEGORY_RECENT, recentLabel, null)) + state.categories.forEach { add(Triple(it.id, it.name, state.categoryCounts[it.id])) } + } + } + + BoxWithConstraints(Modifier.fillMaxSize().background(Color.Black).testTag("iptv-vod")) { + val compact = touch && maxWidth < 600.dp + Column(Modifier.fillMaxSize().padding(horizontal = if (compact) 16.dp else 26.dp)) { + if (!touch) { + Box( + Modifier.fillMaxWidth().height(60.dp).focusRequester(topFocus) + .onFocusChanged { topFocused = it.isFocused } + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) false else when (event.key) { + Key.DirectionLeft -> { topIndex = (topIndex - 1).coerceAtLeast(0); true } + Key.DirectionRight -> { topIndex = (topIndex + 1).coerceAtMost(topBarMaxIndex(hasProfile)); true } + Key.DirectionDown -> { firstTab.requestFocus(); true } + Key.Enter, Key.DirectionCenter, Key.NumPadEnter -> { + when (topBarFocusedItem(topIndex, hasProfile)) { + SidebarItem.SEARCH -> onNavigateToSearch() + SidebarItem.HOME -> onNavigateToHome() + SidebarItem.WATCHLIST -> onNavigateToWatchlist() + SidebarItem.TV -> onNavigateToTv() + SidebarItem.VOD -> firstTab.requestFocus() + SidebarItem.SETTINGS -> onNavigateToSettings("general") + null -> onSwitchProfile() + } + true + } + else -> false + } + } + .focusable() + ) { + AppTopBar(SidebarItem.VOD, topFocused, topIndex, profile = currentProfile, modifier = Modifier.offset(y = (-6).dp)) + } + } else { + Spacer(Modifier.height(12.dp)) + } + + // Tabs + actions + Row( + Modifier.fillMaxWidth().padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OledControl( + stringResource(R.string.vod_movies), + selected = !state.isSeries, + modifier = Modifier.focusRequester(firstTab).onGloballyPositioned { + if (!touch && !initialFocusPlaced) { initialFocusPlaced = true; firstTab.requestFocus() } + }, + onClick = { viewModel.selectTab(series = false) } + ) + OledControl( + stringResource(R.string.vod_series), + selected = state.isSeries, + onClick = { viewModel.selectTab(series = true) } + ) + Spacer(Modifier.weight(1f)) + if (!compact && state.hasSource && !state.isLoading) { + Text( + stringResource(R.string.vod_titles_count, state.items.size), + color = Color.LightGray, + fontSize = 13.sp + ) + } + if (state.query.isNotEmpty()) { + OledControl("× ${state.query}", onClick = { viewModel.setQuery("") }) + } + OledControl("⌕", onClick = { searchOpen = true }) + if (!compact) OledControl(stringResource(R.string.vod_refresh), onClick = { viewModel.refresh() }) + } + + when { + !state.hasSource -> VodMessage( + title = stringResource(R.string.vod_no_source_title), + detail = stringResource(R.string.vod_no_source_desc), + action = stringResource(R.string.vod_open_settings), + onAction = { onNavigateToSettings("iptv") } + ) + state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(color = resolveAccentColor(Color.White)) + Text( + stringResource(R.string.vod_loading), + color = Color.Gray, + fontSize = 13.sp, + modifier = Modifier.padding(top = 12.dp) + ) + } + } + state.error != null -> VodMessage( + title = stringResource(R.string.vod_load_failed), + detail = state.error.orEmpty(), + action = stringResource(R.string.vod_retry), + onAction = { viewModel.refresh() } + ) + else -> { + if (compact) { + LazyRow( + Modifier.fillMaxWidth().padding(bottom = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(categoryEntries, key = { it.first }) { (id, name, _) -> + OledControl(name, selected = state.selectedCategoryId == id, compact = true, + onClick = { viewModel.selectCategory(id) }) + } + } + } + Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(18.dp)) { + if (!compact) { + LazyColumn( + Modifier.width(if (touch) 200.dp else 230.dp).fillMaxHeight().testTag("iptv-vod-categories"), + verticalArrangement = Arrangement.spacedBy(2.dp), + contentPadding = PaddingValues(bottom = 24.dp + LocalBottomBarInset.current) + ) { + items(categoryEntries, key = { it.first }) { (id, name, count) -> + OledControl( + if (count != null) "$name · $count" else name, + selected = state.selectedCategoryId == id, + modifier = Modifier.fillMaxWidth(), + onClick = { viewModel.selectCategory(id) } + ) + } + } + } + BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) { + val columns = (maxWidth.value / if (touch) 110f else 150f).toInt().coerceIn(2, 9) + if (state.items.isEmpty()) { + Text( + stringResource(R.string.vod_empty), + color = Color.Gray, + modifier = Modifier.align(Alignment.Center) + ) + } else { + LazyVerticalGrid( + GridCells.Fixed(columns), + state = grid, + modifier = Modifier.fillMaxSize().testTag("iptv-vod-grid"), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(top = 6.dp, bottom = 16.dp + LocalBottomBarInset.current) + ) { + items(state.items, key = { it.key }) { item -> + VodPosterCard( + item = item, + isResolving = state.resolvingKey == item.key, + onClick = { viewModel.open(item) } + ) + } + } + } + } + } + } + } + } + state.message?.let { name -> + Toast( + message = stringResource(R.string.vod_not_matched, name), + type = ToastType.INFO, + isVisible = true, + onDismiss = viewModel::dismissMessage + ) + } + } + + TextInputModal( + isVisible = searchOpen, + title = stringResource(R.string.vod_search), + initialValue = state.query, + onConfirm = { viewModel.setQuery(it); searchOpen = false }, + onCancel = { searchOpen = false } + ) +} + +@Composable +private fun VodPosterCard(item: IptvVodBrowseItem, isResolving: Boolean, onClick: () -> Unit) { + var focused by remember { mutableStateOf(false) } + val accent = resolveAccentColor(Color.White) + val borderColor by animateColorAsState(if (focused) accent else Color.Transparent, tween(120), label = "vod-card-border") + Column( + Modifier + .fillMaxWidth() + .onFocusChanged { focused = it.isFocused } + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .testTag("iptv-vod-card-${item.key}") + ) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(2f / 3f) + .clip(RoundedCornerShape(8.dp)) + .border(2.dp, borderColor, RoundedCornerShape(8.dp)) + .background(Color(0xFF151518)) + ) { + // Fallback title stays underneath, visible when the provider has no artwork. + Text( + item.name, + color = Color.Gray, + fontSize = 13.sp, + textAlign = TextAlign.Center, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.align(Alignment.Center).padding(8.dp) + ) + if (item.posterUrl != null) { + AsyncImage( + model = item.posterUrl, + contentDescription = item.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + item.rating?.let { rating -> + Text( + "★ $rating", + color = Color.White, + fontSize = 11.sp, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(5.dp) + .background(Color.Black.copy(alpha = 0.65f), RoundedCornerShape(4.dp)) + .padding(horizontal = 5.dp, vertical = 2.dp) + ) + } + if (isResolving) { + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.55f)), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(28.dp), color = accent) + } + } + } + val ink = if (focused && accent.luminance() > .4f) Color.White else Color.White + Text( + item.name, + color = ink, + fontSize = 13.sp, + fontWeight = if (focused) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 6.dp, start = 2.dp, end = 2.dp) + ) + item.year?.let { + Text(it.toString(), color = Color.Gray, fontSize = 11.sp, modifier = Modifier.padding(start = 2.dp)) + } + } +} + +@Composable +private fun VodMessage(title: String, detail: String, action: String, onAction: () -> Unit) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.widthIn(max = 520.dp)) { + Text(title, color = Color.White, fontSize = 20.sp, textAlign = TextAlign.Center) + Text( + detail, + color = Color.Gray, + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp) + ) + OledControl(action, onClick = onAction) + } + } +} 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..fe5a4c403 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/vod/IptvVodViewModel.kt @@ -0,0 +1,195 @@ +package com.arflix.tv.ui.screens.vod + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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 + +/** 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, + val hasSource: Boolean = true, + 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() + + init { + load() + } + + fun selectTab(series: Boolean) { + if (_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() { + 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, + 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, 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( + // Negative synthetic id: never collides with TMDB, and the player + // skips history/scrobbling for ids <= 0. + IptvVodEvent.PlayDirect( + mediaId = -(item.key.hashCode() and 0x7fffffff).coerceAtLeast(1), + 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(' ', '-', '|', ':', '.') diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index dca02db3e..855e71209 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -295,4 +295,20 @@ הוספת התוסף נכשלה קטלוג TMDB השתמש בכתובת של דף TMDB מסוג list, collection, company, network, person, keyword או genre + VOD + סרטים + סדרות + הכל + נוספו לאחרונה + %1$d כותרים + חיפוש ב-VOD + רענון + טוען את הקטלוג של הספק. בטעינה הראשונה של קטלוג גדול זה יכול לקחת רגע + אין מקור VOD של IPTV + הוסף פלייליסט IPTV מסוג Xtream Codes בהגדרות, IPTV, כדי לדפדף כאן בסרטים ובסדרות שלו. + פתח הגדרות IPTV + לא נמצאו כותרים + לא ניתן לטעון את קטלוג ה-VOD + נסה שוב + לא ניתן לפתוח את \"%1$s\" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8022b4237..b466fc0b7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1883,5 +1883,21 @@ Connect SIMKL SIMKL connected Open SIMKL + VOD + Movies + Series + All + Recently added + %1$d titles + Search VOD + Refresh + Loading the provider catalog. The first load of a large catalog can take a moment + No IPTV VOD source + Add an Xtream Codes IPTV playlist in Settings, IPTV, to browse its movies and series here. + Open IPTV settings + No titles found + Could not load the VOD catalog + Retry + Could not open \"%1$s\" 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")) + } +} From c2ee8cdf05a43e6090cab0ff2a756c9dfdfd4d42 Mon Sep 17 00:00:00 2001 From: koby455 Date: Tue, 6 Oct 2026 19:47:01 +0300 Subject: [PATCH 2/3] Move IPTV VOD browsing into the Library as a tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #730: a VOD destination in the top bar is not where this belongs. The TV screen goes back to being live-only and the top-bar entry is gone; the catalogue, its caches and the TMDB matching stay and the browsing UI moves into Library, next to the home servers. The tab reuses what is already there: the sources rail lists "All", "Recently added" and the provider's own categories (empty ones hidden), the Library's own search box searches it, and Filters > Type chooses between the provider's movie and series catalogues — two separate lists on its side, so there is no combined option and no sort, the order being the provider's own. The tab only appears when a playlist can actually serve VOD, and goes away again when that playlist does. The catalogue is read the first time the tab is opened rather than when Library is, so opening Library still costs only a config read. Deletes the standalone VOD screen and the strings that belonged to it. Co-Authored-By: Claude Opus 5 --- .../com/arflix/tv/navigation/AppNavigation.kt | 33 +- .../arflix/tv/ui/components/AppBottomBar.kt | 3 - .../com/arflix/tv/ui/components/Sidebar.kt | 3 - .../tv/ui/screens/details/DetailsScreen.kt | 2 - .../arflix/tv/ui/screens/home/HomeScreen.kt | 4 - .../tv/ui/screens/search/SearchScreen.kt | 3 +- .../tv/ui/screens/settings/SettingsScreen.kt | 2 - .../com/arflix/tv/ui/screens/tv/TvScreen.kt | 2 - .../tv/ui/screens/tv/live/LiveTvScreen.kt | 2 - .../arflix/tv/ui/screens/vod/IptvVodScreen.kt | 377 ------------------ .../tv/ui/screens/vod/IptvVodViewModel.kt | 54 ++- .../ui/screens/watchlist/WatchlistScreen.kt | 137 +++++-- .../screens/watchlist/WatchlistViewModel.kt | 16 + .../kotlin/com/arflix/tv/util/AppLanguage.kt | 2 + app/src/main/res/values-iw/strings.xml | 17 +- app/src/main/res/values/strings.xml | 17 +- 16 files changed, 177 insertions(+), 497 deletions(-) delete mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/vod/IptvVodScreen.kt 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 efc7807a1..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,7 +33,6 @@ 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.ui.screens.vod.IptvVodScreen import com.arflix.tv.data.model.IptvVodSourceIds import com.arflix.tv.ui.screens.profile.ProfileSelectionScreen import com.arflix.tv.util.LocalDeviceType @@ -46,7 +45,6 @@ sealed class Screen(val route: String) { data object Home : Screen("home") data object Search : Screen("search") data object Watchlist : Screen("watchlist") - data object Vod : Screen("vod") data object CollectionDetails : Screen("collections/{catalogId}") { fun createRoute(catalogId: String): String { return "collections/${android.net.Uri.encode(catalogId)}" @@ -233,7 +231,6 @@ fun AppNavigation( onNavigateToWatchlist = { navigateTopLevel(Screen.Watchlist.route) }, - onNavigateToVod = { navigateTopLevel(Screen.Vod.route) }, onNavigateToTv = { channelId, streamUrl -> navigateTopLevel(Screen.Tv.createRoute(channelId, streamUrl)) }, @@ -269,7 +266,6 @@ fun AppNavigation( }, onNavigateToHome = { navigateHome() }, onNavigateToWatchlist = { navigateTopLevel(Screen.Watchlist.route) }, - onNavigateToVod = { navigateTopLevel(Screen.Vod.route) }, onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) }, onNavigateToSettings = { navigateTopLevel(Screen.Settings.route) }, onSwitchProfile = { @@ -289,29 +285,8 @@ fun AppNavigation( onNavigateToDetails = { mediaType, mediaId -> navController.navigate(Screen.Details.createRoute(mediaType, mediaId)) }, - onNavigateToHome = { navigateHome() }, - onNavigateToSearch = { navigateTopLevel(Screen.Search.route) }, - onNavigateToVod = { navigateTopLevel(Screen.Vod.route) }, - onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) }, - onNavigateToSettings = { section -> - navigateTopLevel(Screen.Settings.createRoute(initialSection = section)) - }, - onSwitchProfile = { - onSwitchProfile() - navController.navigateToProfileSelection() - }, - onBack = { navigateHome() } - ) - } - - // IPTV VOD catalogue (top-bar entry next to TV) - composable(Screen.Vod.route) { - IptvVodScreen( - currentProfile = currentProfile, - onNavigateToDetails = { mediaType, mediaId -> - navController.navigate(Screen.Details.createRoute(mediaType, mediaId)) - }, - onPlayDirect = { mediaId, streamUrl, title -> + // An IPTV movie TMDB does not know: play the provider's copy. + onPlayIptvVod = { mediaId, streamUrl, title -> navController.navigate( Screen.Player.createRoute( mediaType = MediaType.MOVIE, @@ -324,7 +299,6 @@ fun AppNavigation( }, onNavigateToHome = { navigateHome() }, onNavigateToSearch = { navigateTopLevel(Screen.Search.route) }, - onNavigateToWatchlist = { navigateTopLevel(Screen.Watchlist.route) }, onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) }, onNavigateToSettings = { section -> navigateTopLevel(Screen.Settings.createRoute(initialSection = section)) @@ -356,7 +330,6 @@ fun AppNavigation( onNavigateToHome = { navigateHome() }, onNavigateToSearch = { navigateTopLevel(Screen.Search.route) }, onNavigateToWatchlist = { navigateTopLevel(Screen.Watchlist.route) }, - onNavigateToVod = { navigateTopLevel(Screen.Vod.route) }, onNavigateToSettings = { navigateTopLevel(Screen.Settings.route) }, onNavigateToIptvSettings = { navigateTopLevel(Screen.Settings.createRoute(initialSection = "iptv")) }, onNavigateToDetails = { mediaType, mediaId -> @@ -407,7 +380,6 @@ fun AppNavigation( installAddonUrl = installAddonUrl, onNavigateToHome = { navigateHome() }, onNavigateToSearch = { navigateTopLevel(Screen.Search.route) }, - onNavigateToVod = { navigateTopLevel(Screen.Vod.route) }, onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) }, onNavigateToWatchlist = { navigateTopLevel(Screen.Watchlist.route) }, onNavigateToTelegramSettings = { navController.navigate(Screen.TelegramSettings.route) }, @@ -568,7 +540,6 @@ fun AppNavigation( onNavigateToSearch = { navigateTopLevel(Screen.Search.route) }, - onNavigateToVod = { navigateTopLevel(Screen.Vod.route) }, onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) }, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt index 70c6168bb..b86ef1187 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt @@ -36,7 +36,6 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.LiveTv import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -149,7 +148,6 @@ internal fun shouldShowBottomBar( route == Screen.Search.route -> true route == Screen.Watchlist.route -> true route == "tv" -> true - route == "vod" -> true route == "settings" -> true else -> false } @@ -166,7 +164,6 @@ val bottomBarItems = listOf( BottomBarItem(R.string.search, Icons.Default.Search, "search"), BottomBarItem(R.string.nav_library, Icons.Default.Bookmark, "watchlist"), BottomBarItem(R.string.topbar_tv, Icons.Default.LiveTv, "tv"), - BottomBarItem(R.string.nav_vod, Icons.Default.VideoLibrary, "vod"), BottomBarItem(R.string.settings, Icons.Default.Settings, "settings") ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt index 4b717d85b..059ba8a7e 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt @@ -28,7 +28,6 @@ import androidx.compose.material.icons.outlined.Bookmark import androidx.compose.material.icons.outlined.LiveTv import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.outlined.VideoLibrary import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -63,8 +62,6 @@ enum class SidebarItem(val icon: ImageVector, @StringRes val labelRes: Int) { HOME(Icons.Outlined.Home, R.string.home), WATCHLIST(Icons.Outlined.Bookmark, R.string.nav_library), TV(Icons.Outlined.LiveTv, R.string.tv_shows), - // IPTV provider VOD catalogue, right next to TV. - VOD(Icons.Outlined.VideoLibrary, R.string.nav_vod), SETTINGS(Icons.Outlined.Settings, R.string.settings) } 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 74a5642d3..352ba9e59 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 @@ -249,7 +249,6 @@ fun DetailsScreen( onNavigateToSearch: () -> Unit = {}, onNavigateToWatchlist: () -> Unit = {}, onNavigateToTv: () -> Unit = {}, - onNavigateToVod: () -> Unit = {}, onNavigateToSettings: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, onFullscreenChanged: (Boolean) -> Unit = {}, @@ -775,7 +774,6 @@ fun DetailsScreen( SidebarItem.HOME -> onNavigateToHome() SidebarItem.WATCHLIST -> onNavigateToWatchlist() SidebarItem.TV -> onNavigateToTv() - SidebarItem.VOD -> onNavigateToVod() SidebarItem.SETTINGS -> onNavigateToSettings() null -> Unit } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index 116383257..c40ee3956 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -720,7 +720,6 @@ fun HomeScreen( onNavigateToSearch: () -> Unit = {}, onNavigateToWatchlist: () -> Unit = {}, onNavigateToTv: (channelId: String?, streamUrl: String?) -> Unit = { _, _ -> }, - onNavigateToVod: () -> Unit = {}, onNavigateToPlayer: (MediaType, Int, String, String?, String?) -> Unit = { _, _, _, _, _ -> }, onNavigateToSettings: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, @@ -1356,7 +1355,6 @@ fun HomeScreen( onNavigateToSearch = onNavigateToSearch, onNavigateToWatchlist = onNavigateToWatchlist, onNavigateToTv = onNavigateToTv, - onNavigateToVod = onNavigateToVod, isSportsHomeItem = { item -> viewModel.isSportsHomeItem(item) }, onSportsHomeItemClick = openSportsHomeItem, onNavigateToSettings = onNavigateToSettings, @@ -2484,7 +2482,6 @@ internal fun HomeInputLayer( onNavigateToSearch: () -> Unit, onNavigateToWatchlist: () -> Unit, onNavigateToTv: (channelId: String?, streamUrl: String?) -> Unit, - onNavigateToVod: () -> Unit = {}, isSportsHomeItem: (MediaItem) -> Boolean = { false }, onSportsHomeItemClick: (MediaItem) -> Unit = {}, onNavigateToSettings: () -> Unit, @@ -2642,7 +2639,6 @@ internal fun HomeInputLayer( SidebarItem.HOME -> Unit SidebarItem.WATCHLIST -> onNavigateToWatchlist() SidebarItem.TV -> onNavigateToTv(null, null) - SidebarItem.VOD -> onNavigateToVod() SidebarItem.SETTINGS -> onNavigateToSettings() null -> Unit } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt index 2818e6328..8d0c10aed 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt @@ -151,7 +151,6 @@ fun SearchScreen( onNavigateToHome: () -> Unit = {}, onNavigateToWatchlist: () -> Unit = {}, onNavigateToTv: () -> Unit = {}, - onNavigateToVod: () -> Unit = {}, onNavigateToSettings: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, onBack: () -> Unit = {} @@ -697,7 +696,7 @@ fun SearchScreen( FocusZone.PANEL -> false FocusZone.SIDEBAR -> { if (hasProfile && sidebarFocusIndex == 0) onSwitchProfile() - else when (topBarFocusedItem(sidebarFocusIndex, hasProfile)) { SidebarItem.SEARCH -> Unit; SidebarItem.HOME -> onNavigateToHome(); SidebarItem.WATCHLIST -> onNavigateToWatchlist(); SidebarItem.TV -> onNavigateToTv(); SidebarItem.VOD -> onNavigateToVod(); SidebarItem.SETTINGS -> onNavigateToSettings(); null -> Unit } + else when (topBarFocusedItem(sidebarFocusIndex, hasProfile)) { SidebarItem.SEARCH -> Unit; SidebarItem.HOME -> onNavigateToHome(); SidebarItem.WATCHLIST -> onNavigateToWatchlist(); SidebarItem.TV -> onNavigateToTv(); SidebarItem.SETTINGS -> onNavigateToSettings(); null -> Unit } true } FocusZone.SEARCH_INPUT -> { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 61032ef97..ef71275b4 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -543,7 +543,6 @@ fun SettingsScreen( onNavigateToHome: () -> Unit = {}, onNavigateToSearch: () -> Unit = {}, onNavigateToTv: () -> Unit = {}, - onNavigateToVod: () -> Unit = {}, onNavigateToWatchlist: () -> Unit = {}, onNavigateToTelegramSettings: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, @@ -1407,7 +1406,6 @@ fun SettingsScreen( SidebarItem.SEARCH -> onNavigateToSearch() SidebarItem.HOME -> onNavigateToHome() SidebarItem.TV -> onNavigateToTv() - SidebarItem.VOD -> onNavigateToVod() SidebarItem.WATCHLIST -> onNavigateToWatchlist() SidebarItem.SETTINGS -> { /* Already here */ } null -> Unit diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt index 77487bd6a..9ad6db0a1 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt @@ -271,7 +271,6 @@ fun TvScreen( onNavigateToHome: () -> Unit = {}, onNavigateToSearch: () -> Unit = {}, onNavigateToWatchlist: () -> Unit = {}, - onNavigateToVod: () -> Unit = {}, onNavigateToSettings: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, onBack: () -> Unit = {} @@ -921,7 +920,6 @@ fun TvScreen( SidebarItem.HOME -> onNavigateToHome() SidebarItem.WATCHLIST -> onNavigateToWatchlist() SidebarItem.TV -> Unit - SidebarItem.VOD -> onNavigateToVod() SidebarItem.SETTINGS -> onNavigateToSettings() null -> Unit } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt index 3caf5f527..767be638d 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt @@ -549,7 +549,6 @@ fun LiveTvScreen( onNavigateToHome: () -> Unit = {}, onNavigateToSearch: () -> Unit = {}, onNavigateToWatchlist: () -> Unit = {}, - onNavigateToVod: () -> Unit = {}, onNavigateToSettings: () -> Unit = {}, onNavigateToIptvSettings: (() -> Unit)? = null, onNavigateToDetails: (com.arflix.tv.data.model.MediaType, Int) -> Unit = { _, _ -> }, @@ -3874,7 +3873,6 @@ fun LiveTvScreen( SidebarItem.HOME -> onNavigateToHome() SidebarItem.WATCHLIST -> onNavigateToWatchlist() SidebarItem.TV -> Unit - SidebarItem.VOD -> onNavigateToVod() SidebarItem.SETTINGS -> onNavigateToSettings() null -> Unit } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/vod/IptvVodScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/vod/IptvVodScreen.kt deleted file mode 100644 index 5853f7756..000000000 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/vod/IptvVodScreen.kt +++ /dev/null @@ -1,377 +0,0 @@ -package com.arflix.tv.ui.screens.vod - -import androidx.activity.compose.BackHandler -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.key.* -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil.compose.AsyncImage -import com.arflix.tv.R -import com.arflix.tv.data.model.MediaType -import com.arflix.tv.data.model.Profile -import com.arflix.tv.data.repository.IptvRepository.IptvVodBrowseItem -import com.arflix.tv.ui.components.AppTopBar -import com.arflix.tv.ui.components.LocalBottomBarInset -import com.arflix.tv.ui.components.SidebarItem -import com.arflix.tv.ui.components.TextInputModal -import com.arflix.tv.ui.components.Toast -import com.arflix.tv.ui.components.ToastType -import com.arflix.tv.ui.components.topBarFocusedItem -import com.arflix.tv.ui.components.topBarMaxIndex -import com.arflix.tv.ui.components.topBarSelectedIndex -import com.arflix.tv.ui.screens.watchlist.OledControl -import com.arflix.tv.ui.skin.resolveAccentColor -import com.arflix.tv.util.LocalDeviceType - -/** - * Browses the IPTV provider's own VOD catalogue (movies / series in the provider's - * categories). Reached from the "VOD" entry next to TV in the top bar. - */ -@Composable -fun IptvVodScreen( - viewModel: IptvVodViewModel = hiltViewModel(), - currentProfile: Profile? = null, - onNavigateToDetails: (MediaType, Int) -> Unit = { _, _ -> }, - onPlayDirect: (mediaId: Int, streamUrl: String, title: String) -> Unit = { _, _, _ -> }, - onNavigateToHome: () -> Unit = {}, - onNavigateToSearch: () -> Unit = {}, - onNavigateToWatchlist: () -> Unit = {}, - onNavigateToTv: () -> Unit = {}, - onNavigateToSettings: (String) -> Unit = {}, - onSwitchProfile: () -> Unit = {}, - onBack: () -> Unit = {} -) { - val state by viewModel.uiState.collectAsStateWithLifecycle() - val touch = LocalDeviceType.current.isTouchDevice() - val hasProfile = currentProfile != null - var topFocused by remember { mutableStateOf(false) } - var topIndex by remember { mutableIntStateOf(topBarSelectedIndex(SidebarItem.VOD, hasProfile).coerceAtLeast(0)) } - val topFocus = remember { FocusRequester() } - val firstTab = remember { FocusRequester() } - var initialFocusPlaced by remember { mutableStateOf(false) } - var searchOpen by remember { mutableStateOf(false) } - val grid = rememberLazyGridState() - - LaunchedEffect(viewModel) { - viewModel.events.collect { event -> - when (event) { - is IptvVodEvent.OpenDetails -> onNavigateToDetails(event.mediaType, event.tmdbId) - is IptvVodEvent.PlayDirect -> onPlayDirect(event.mediaId, event.streamUrl, event.title) - } - } - } - LaunchedEffect(state.selectedCategoryId, state.isSeries, state.query) { grid.scrollToItem(0) } - - BackHandler(!searchOpen) { - when { - state.query.isNotEmpty() -> viewModel.setQuery("") - !touch && !topFocused -> topFocus.requestFocus() - else -> onBack() - } - } - - val allLabel = stringResource(R.string.vod_all) - val recentLabel = stringResource(R.string.vod_recent) - val categoryEntries = remember(state.categories, state.categoryCounts, allLabel, recentLabel) { - buildList { - add(Triple(VOD_CATEGORY_ALL, allLabel, state.categoryCounts.values.sum())) - add(Triple(VOD_CATEGORY_RECENT, recentLabel, null)) - state.categories.forEach { add(Triple(it.id, it.name, state.categoryCounts[it.id])) } - } - } - - BoxWithConstraints(Modifier.fillMaxSize().background(Color.Black).testTag("iptv-vod")) { - val compact = touch && maxWidth < 600.dp - Column(Modifier.fillMaxSize().padding(horizontal = if (compact) 16.dp else 26.dp)) { - if (!touch) { - Box( - Modifier.fillMaxWidth().height(60.dp).focusRequester(topFocus) - .onFocusChanged { topFocused = it.isFocused } - .onKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) false else when (event.key) { - Key.DirectionLeft -> { topIndex = (topIndex - 1).coerceAtLeast(0); true } - Key.DirectionRight -> { topIndex = (topIndex + 1).coerceAtMost(topBarMaxIndex(hasProfile)); true } - Key.DirectionDown -> { firstTab.requestFocus(); true } - Key.Enter, Key.DirectionCenter, Key.NumPadEnter -> { - when (topBarFocusedItem(topIndex, hasProfile)) { - SidebarItem.SEARCH -> onNavigateToSearch() - SidebarItem.HOME -> onNavigateToHome() - SidebarItem.WATCHLIST -> onNavigateToWatchlist() - SidebarItem.TV -> onNavigateToTv() - SidebarItem.VOD -> firstTab.requestFocus() - SidebarItem.SETTINGS -> onNavigateToSettings("general") - null -> onSwitchProfile() - } - true - } - else -> false - } - } - .focusable() - ) { - AppTopBar(SidebarItem.VOD, topFocused, topIndex, profile = currentProfile, modifier = Modifier.offset(y = (-6).dp)) - } - } else { - Spacer(Modifier.height(12.dp)) - } - - // Tabs + actions - Row( - Modifier.fillMaxWidth().padding(bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OledControl( - stringResource(R.string.vod_movies), - selected = !state.isSeries, - modifier = Modifier.focusRequester(firstTab).onGloballyPositioned { - if (!touch && !initialFocusPlaced) { initialFocusPlaced = true; firstTab.requestFocus() } - }, - onClick = { viewModel.selectTab(series = false) } - ) - OledControl( - stringResource(R.string.vod_series), - selected = state.isSeries, - onClick = { viewModel.selectTab(series = true) } - ) - Spacer(Modifier.weight(1f)) - if (!compact && state.hasSource && !state.isLoading) { - Text( - stringResource(R.string.vod_titles_count, state.items.size), - color = Color.LightGray, - fontSize = 13.sp - ) - } - if (state.query.isNotEmpty()) { - OledControl("× ${state.query}", onClick = { viewModel.setQuery("") }) - } - OledControl("⌕", onClick = { searchOpen = true }) - if (!compact) OledControl(stringResource(R.string.vod_refresh), onClick = { viewModel.refresh() }) - } - - when { - !state.hasSource -> VodMessage( - title = stringResource(R.string.vod_no_source_title), - detail = stringResource(R.string.vod_no_source_desc), - action = stringResource(R.string.vod_open_settings), - onAction = { onNavigateToSettings("iptv") } - ) - state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator(color = resolveAccentColor(Color.White)) - Text( - stringResource(R.string.vod_loading), - color = Color.Gray, - fontSize = 13.sp, - modifier = Modifier.padding(top = 12.dp) - ) - } - } - state.error != null -> VodMessage( - title = stringResource(R.string.vod_load_failed), - detail = state.error.orEmpty(), - action = stringResource(R.string.vod_retry), - onAction = { viewModel.refresh() } - ) - else -> { - if (compact) { - LazyRow( - Modifier.fillMaxWidth().padding(bottom = 10.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - items(categoryEntries, key = { it.first }) { (id, name, _) -> - OledControl(name, selected = state.selectedCategoryId == id, compact = true, - onClick = { viewModel.selectCategory(id) }) - } - } - } - Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(18.dp)) { - if (!compact) { - LazyColumn( - Modifier.width(if (touch) 200.dp else 230.dp).fillMaxHeight().testTag("iptv-vod-categories"), - verticalArrangement = Arrangement.spacedBy(2.dp), - contentPadding = PaddingValues(bottom = 24.dp + LocalBottomBarInset.current) - ) { - items(categoryEntries, key = { it.first }) { (id, name, count) -> - OledControl( - if (count != null) "$name · $count" else name, - selected = state.selectedCategoryId == id, - modifier = Modifier.fillMaxWidth(), - onClick = { viewModel.selectCategory(id) } - ) - } - } - } - BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) { - val columns = (maxWidth.value / if (touch) 110f else 150f).toInt().coerceIn(2, 9) - if (state.items.isEmpty()) { - Text( - stringResource(R.string.vod_empty), - color = Color.Gray, - modifier = Modifier.align(Alignment.Center) - ) - } else { - LazyVerticalGrid( - GridCells.Fixed(columns), - state = grid, - modifier = Modifier.fillMaxSize().testTag("iptv-vod-grid"), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(top = 6.dp, bottom = 16.dp + LocalBottomBarInset.current) - ) { - items(state.items, key = { it.key }) { item -> - VodPosterCard( - item = item, - isResolving = state.resolvingKey == item.key, - onClick = { viewModel.open(item) } - ) - } - } - } - } - } - } - } - } - state.message?.let { name -> - Toast( - message = stringResource(R.string.vod_not_matched, name), - type = ToastType.INFO, - isVisible = true, - onDismiss = viewModel::dismissMessage - ) - } - } - - TextInputModal( - isVisible = searchOpen, - title = stringResource(R.string.vod_search), - initialValue = state.query, - onConfirm = { viewModel.setQuery(it); searchOpen = false }, - onCancel = { searchOpen = false } - ) -} - -@Composable -private fun VodPosterCard(item: IptvVodBrowseItem, isResolving: Boolean, onClick: () -> Unit) { - var focused by remember { mutableStateOf(false) } - val accent = resolveAccentColor(Color.White) - val borderColor by animateColorAsState(if (focused) accent else Color.Transparent, tween(120), label = "vod-card-border") - Column( - Modifier - .fillMaxWidth() - .onFocusChanged { focused = it.isFocused } - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onClick) - .testTag("iptv-vod-card-${item.key}") - ) { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(2f / 3f) - .clip(RoundedCornerShape(8.dp)) - .border(2.dp, borderColor, RoundedCornerShape(8.dp)) - .background(Color(0xFF151518)) - ) { - // Fallback title stays underneath, visible when the provider has no artwork. - Text( - item.name, - color = Color.Gray, - fontSize = 13.sp, - textAlign = TextAlign.Center, - maxLines = 4, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.align(Alignment.Center).padding(8.dp) - ) - if (item.posterUrl != null) { - AsyncImage( - model = item.posterUrl, - contentDescription = item.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - } - item.rating?.let { rating -> - Text( - "★ $rating", - color = Color.White, - fontSize = 11.sp, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(5.dp) - .background(Color.Black.copy(alpha = 0.65f), RoundedCornerShape(4.dp)) - .padding(horizontal = 5.dp, vertical = 2.dp) - ) - } - if (isResolving) { - Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.55f)), contentAlignment = Alignment.Center) { - CircularProgressIndicator(Modifier.size(28.dp), color = accent) - } - } - } - val ink = if (focused && accent.luminance() > .4f) Color.White else Color.White - Text( - item.name, - color = ink, - fontSize = 13.sp, - fontWeight = if (focused) FontWeight.SemiBold else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 6.dp, start = 2.dp, end = 2.dp) - ) - item.year?.let { - Text(it.toString(), color = Color.Gray, fontSize = 11.sp, modifier = Modifier.padding(start = 2.dp)) - } - } -} - -@Composable -private fun VodMessage(title: String, detail: String, action: String, onAction: () -> Unit) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.widthIn(max = 520.dp)) { - Text(title, color = Color.White, fontSize = 20.sp, textAlign = TextAlign.Center) - Text( - detail, - color = Color.Gray, - fontSize = 14.sp, - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 8.dp, bottom = 16.dp) - ) - OledControl(action, onClick = onAction) - } - } -} 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 index fe5a4c403..259e3c620 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -18,6 +19,14 @@ 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__" @@ -25,7 +34,9 @@ internal const val VOD_CATEGORY_RECENT = "__recent__" data class IptvVodUiState( val isSeries: Boolean = false, val isLoading: Boolean = true, - val hasSource: 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, val error: String? = null, val categories: List = emptyList(), val categoryCounts: Map = emptyMap(), @@ -54,13 +65,26 @@ class IptvVodViewModel @Inject constructor( val events: SharedFlow = _events.asSharedFlow() private var allItems: List = emptyList() + private var started = false init { - load() + // 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.isSeries == series && !_uiState.value.isLoading) return + 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, @@ -86,6 +110,7 @@ class IptvVodViewModel @Inject constructor( } private fun load() { + started = true val series = _uiState.value.isSeries _uiState.value = _uiState.value.copy(isLoading = true, error = null, items = emptyList()) viewModelScope.launch { @@ -148,10 +173,8 @@ class IptvVodViewModel @Inject constructor( when { matchId != null -> _events.tryEmit(IptvVodEvent.OpenDetails(mediaType, matchId)) !item.isSeries && item.streamUrl != null -> _events.tryEmit( - // Negative synthetic id: never collides with TMDB, and the player - // skips history/scrobbling for ids <= 0. IptvVodEvent.PlayDirect( - mediaId = -(item.key.hashCode() and 0x7fffffff).coerceAtLeast(1), + mediaId = item.syntheticMediaId(), streamUrl = item.streamUrl, title = item.name ) @@ -193,3 +216,22 @@ internal fun cleanIptvTitle(raw: String): String = .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..b3aeb37fd 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,38 @@ 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 + val loading = when { iptvMode -> vod.isLoading; 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 +163,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 +199,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 +264,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 +324,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 +355,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 +365,34 @@ fun WatchlistScreen( state.toastMessage?.let { message -> Toast(message = message, isVisible = true, onDismiss = viewModel::dismissToast) } + 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 855e71209..e0b9b9bd9 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -295,20 +295,7 @@ הוספת התוסף נכשלה קטלוג TMDB השתמש בכתובת של דף TMDB מסוג list, collection, company, network, person, keyword או genre - VOD - סרטים - סדרות - הכל - נוספו לאחרונה - %1$d כותרים - חיפוש ב-VOD - רענון - טוען את הקטלוג של הספק. בטעינה הראשונה של קטלוג גדול זה יכול לקחת רגע - אין מקור VOD של IPTV - הוסף פלייליסט IPTV מסוג Xtream Codes בהגדרות, IPTV, כדי לדפדף כאן בסרטים ובסדרות שלו. - פתח הגדרות IPTV - לא נמצאו כותרים - לא ניתן לטעון את קטלוג ה-VOD - נסה שוב לא ניתן לפתוח את \"%1$s\" + IPTV + ספק ה-IPTV לא החזיר כותרים בקטגוריה זו. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b466fc0b7..43488c182 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1883,21 +1883,8 @@ Connect SIMKL SIMKL connected Open SIMKL - VOD - Movies - Series - All - Recently added - %1$d titles - Search VOD - Refresh - Loading the provider catalog. The first load of a large catalog can take a moment - No IPTV VOD source - Add an Xtream Codes IPTV playlist in Settings, IPTV, to browse its movies and series here. - Open IPTV settings - No titles found - Could not load the VOD catalog - Retry Could not open \"%1$s\" + IPTV + Your IPTV provider returned no titles for this category. From 65cdf25ddcfccf0ee17380807ef3c142bf0b4c46 Mon Sep 17 00:00:00 2001 From: koby455 Date: Tue, 6 Oct 2026 19:54:35 +0300 Subject: [PATCH 3/3] Acknowledge the IPTV tab's two waits The catalogue read starts from an effect, so the first frame of the tab rendered an empty grid as "No titles found" before anything had been asked for; it now says it is loading until the first read finishes. Opening a title the provider did not tag with a TMDB id costs a lookup before anything happens on screen, so that wait is shown too, using the state the view model was already tracking for it. Co-Authored-By: Claude Opus 5 --- .../com/arflix/tv/ui/screens/vod/IptvVodViewModel.kt | 6 +++++- .../arflix/tv/ui/screens/watchlist/WatchlistScreen.kt | 11 ++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) 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 index 259e3c620..dce1a32f2 100644 --- 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 @@ -37,6 +37,9 @@ data class IptvVodUiState( // 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(), @@ -127,6 +130,7 @@ class IptvVodViewModel @Inject constructor( 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 }, @@ -135,7 +139,7 @@ class IptvVodViewModel @Inject constructor( applyFilter() }.onFailure { e -> allItems = emptyList() - _uiState.value = _uiState.value.copy(isLoading = false, error = e.message ?: "Load failed") + _uiState.value = _uiState.value.copy(isLoading = false, hasLoaded = true, error = e.message ?: "Load failed") } } } 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 b3aeb37fd..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 @@ -154,7 +154,9 @@ fun WatchlistScreen( if (serverMode) filtered else sortLibraryItems(filtered, sort) } } - val loading = when { iptvMode -> vod.isLoading; serverMode -> servers.isLoading; else -> state.isLoading } + // 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( @@ -365,6 +367,13 @@ 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.