diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 2069679d..ee1f4124 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -128,9 +128,6 @@ import com.nuvio.app.features.player.PlayerLaunch import com.nuvio.app.features.player.PlayerLaunchStore import com.nuvio.app.features.player.PlayerRoute import com.nuvio.app.features.player.PlayerScreen -import com.nuvio.app.features.player.ExternalPlayerOpenResult -import com.nuvio.app.features.player.ExternalPlayerPlatform -import com.nuvio.app.features.player.ExternalPlayerPlaybackRequest import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders import com.nuvio.app.features.profiles.AvatarRepository @@ -291,14 +288,6 @@ private fun NativeNavigationTab.toAppScreenTab(): AppScreenTab = when (this) { NativeNavigationTab.Settings -> AppScreenTab.Settings } -private fun PlayerLaunch.toExternalPlayerPlaybackRequest(): ExternalPlayerPlaybackRequest = - ExternalPlayerPlaybackRequest( - sourceUrl = sourceUrl, - title = title, - streamTitle = streamTitle, - sourceHeaders = sourceHeaders, - ) - private enum class AppGateScreen { Loading, Auth, @@ -531,7 +520,6 @@ private fun MainAppContent( val hapticFeedback = LocalHapticFeedback.current val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } - var searchFocusRequestCount by remember { mutableStateOf(0) } val currentBackStackEntry by navController.currentBackStackEntryAsState() val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle() val liquidGlassNativeTabBarEnabled by remember { @@ -574,9 +562,6 @@ private fun MainAppContent( NetworkStatusRepository.uiState }.collectAsStateWithLifecycle() val downloadedProviderLabel = stringResource(Res.string.provider_downloaded) - val externalPlayerNotConfiguredText = stringResource(Res.string.external_player_not_configured) - val externalPlayerUnavailableText = stringResource(Res.string.external_player_unavailable) - val externalPlayerFailedText = stringResource(Res.string.external_player_failed) val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT var initialHomeReady by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } @@ -598,9 +583,6 @@ private fun MainAppContent( LaunchedEffect(selectedTab) { NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab()) - if (selectedTab != AppScreenTab.Search) { - searchFocusRequestCount = 0 - } } DisposableEffect( @@ -770,29 +752,6 @@ private fun MainAppContent( } } - fun openExternalPlayback(launch: PlayerLaunch): Boolean { - return when ( - ExternalPlayerPlatform.open( - request = launch.toExternalPlayerPlaybackRequest(), - playerId = playerSettingsUiState.externalPlayerId, - ) - ) { - ExternalPlayerOpenResult.Opened -> true - ExternalPlayerOpenResult.NotConfigured -> { - NuvioToastController.show(externalPlayerNotConfiguredText) - false - } - ExternalPlayerOpenResult.NoPlayerAvailable -> { - NuvioToastController.show(externalPlayerUnavailableText) - false - } - ExternalPlayerOpenResult.Failed -> { - NuvioToastController.show(externalPlayerFailedText) - false - } - } - } - fun launchPlaybackWithDownloadPreference( type: String, videoId: String, @@ -824,7 +783,8 @@ private fun MainAppContent( ) val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri) if (!localSourceUrl.isNullOrBlank()) { - val playerLaunch = PlayerLaunch( + val launchId = PlayerLaunchStore.put( + PlayerLaunch( title = title, sourceUrl = localSourceUrl, sourceHeaders = emptyMap(), @@ -847,12 +807,8 @@ private fun MainAppContent( parentMetaType = parentMetaType, initialPositionMs = targetResumePositionMs, initialProgressFraction = targetResumeProgressFraction, - ) - if (playerSettingsUiState.externalPlayerEnabled) { - openExternalPlayback(playerLaunch) - return - } - val launchId = PlayerLaunchStore.put(playerLaunch) + ), + ) navController.navigate(PlayerRoute(launchId = launchId)) return } @@ -1053,13 +1009,7 @@ private fun MainAppContent( ) NavItem( selected = selectedTab == AppScreenTab.Search, - onClick = { - if (selectedTab == AppScreenTab.Search) { - searchFocusRequestCount++ - } else { - selectedTab = AppScreenTab.Search - } - }, + onClick = { selectedTab = AppScreenTab.Search }, icon = Res.drawable.sidebar_search, contentDescription = stringResource(Res.string.compose_nav_search), ) @@ -1093,7 +1043,6 @@ private fun MainAppContent( .fillMaxSize() .padding(innerPadding), selectedTab = selectedTab, - searchFocusRequestCount = searchFocusRequestCount, animateHomeCollectionGifs = tabsRouteActive, onCatalogClick = onCatalogClick, onPosterClick = { meta -> @@ -1148,13 +1097,7 @@ private fun MainAppContent( if (isTabletLayout && !useNativeBottomTabs) { TabletFloatingTopBar( selectedTab = selectedTab, - onTabSelected = { tab -> - if (tab == AppScreenTab.Search && selectedTab == AppScreenTab.Search) { - searchFocusRequestCount++ - } else { - selectedTab = tab - } - }, + onTabSelected = { selectedTab = it }, onProfileSelected = onProfileSelected, onAddProfileRequested = onSwitchProfile, ) @@ -1405,8 +1348,10 @@ private fun MainAppContent( val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs) if (cached != null) { + reuseNavigated = true StreamsRepository.clear() - val playerLaunch = PlayerLaunch( + val launchId = PlayerLaunchStore.put( + PlayerLaunch( title = launch.title, sourceUrl = cached.url, sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders), @@ -1431,13 +1376,7 @@ private fun MainAppContent( initialPositionMs = launch.resumePositionMs ?: 0L, initialProgressFraction = launch.resumeProgressFraction, ) - if (playerSettings.externalPlayerEnabled) { - openExternalPlayback(playerLaunch) - reuseNavigated = true - return@LaunchedEffect - } - reuseNavigated = true - val launchId = PlayerLaunchStore.put(playerLaunch) + ) navController.navigate(PlayerRoute(launchId = launchId)) { popUpTo { inclusive = true } } @@ -1489,7 +1428,8 @@ private fun MainAppContent( bingeGroup = stream.behaviorHints.bingeGroup, ) } - val playerLaunch = PlayerLaunch( + val launchId = PlayerLaunchStore.put( + PlayerLaunch( title = launch.title, sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), @@ -1514,13 +1454,9 @@ private fun MainAppContent( initialPositionMs = launch.resumePositionMs ?: 0L, initialProgressFraction = launch.resumeProgressFraction, ) + ) StreamsRepository.consumeAutoPlay() StreamsRepository.cancelLoading() - if (playerSettings.externalPlayerEnabled) { - openExternalPlayback(playerLaunch) - return@LaunchedEffect - } - val launchId = PlayerLaunchStore.put(playerLaunch) navController.navigate(PlayerRoute(launchId = launchId)) { popUpTo { inclusive = true } } @@ -1536,74 +1472,6 @@ private fun MainAppContent( return@composable } - fun openSelectedStream( - stream: com.nuvio.app.features.streams.StreamItem, - resolvedResumePositionMs: Long?, - resolvedResumeProgressFraction: Float?, - forceExternal: Boolean, - forceInternal: Boolean, - ) { - val sourceUrl = stream.directPlaybackUrl ?: return - if (playerSettings.streamReuseLastLinkEnabled) { - val cacheKey = StreamLinkCacheRepository.contentKey( - type = launch.type, - videoId = effectiveVideoId, - parentMetaId = launch.parentMetaId, - season = launch.seasonNumber, - episode = launch.episodeNumber, - ) - StreamLinkCacheRepository.save( - contentKey = cacheKey, - url = sourceUrl, - streamName = stream.streamLabel, - addonName = stream.addonName, - addonId = stream.addonId, - requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), - responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), - filename = stream.behaviorHints.filename, - videoSize = stream.behaviorHints.videoSize, - bingeGroup = stream.behaviorHints.bingeGroup, - ) - } - val playerLaunch = PlayerLaunch( - title = launch.title, - sourceUrl = sourceUrl, - sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), - sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), - logo = launch.logo, - poster = launch.poster, - background = launch.background, - seasonNumber = launch.seasonNumber, - episodeNumber = launch.episodeNumber, - episodeTitle = launch.episodeTitle, - episodeThumbnail = launch.episodeThumbnail, - streamTitle = stream.streamLabel, - streamSubtitle = stream.streamSubtitle, - bingeGroup = stream.behaviorHints.bingeGroup, - pauseDescription = pauseDescription, - providerName = stream.addonName, - providerAddonId = stream.addonId, - contentType = launch.type, - videoId = effectiveVideoId, - parentMetaId = launch.parentMetaId ?: effectiveVideoId, - parentMetaType = launch.parentMetaType ?: launch.type, - initialPositionMs = resolvedResumePositionMs ?: 0L, - initialProgressFraction = resolvedResumeProgressFraction, - ) - - if (!forceInternal && (forceExternal || playerSettings.externalPlayerEnabled)) { - openExternalPlayback(playerLaunch) - StreamsRepository.cancelLoading() - return - } - - val launchId = PlayerLaunchStore.put(playerLaunch) - StreamsRepository.cancelLoading() - navController.navigate( - PlayerRoute(launchId = launchId) - ) - } - StreamsScreen( type = launch.type, videoId = effectiveVideoId, @@ -1622,22 +1490,62 @@ private fun MainAppContent( manualSelection = launch.manualSelection, startFromBeginning = launch.startFromBeginning, onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction -> - openSelectedStream( - stream = stream, - resolvedResumePositionMs = resolvedResumePositionMs, - resolvedResumeProgressFraction = resolvedResumeProgressFraction, - forceExternal = false, - forceInternal = false, - ) - }, - onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction -> - openSelectedStream( - stream = stream, - resolvedResumePositionMs = resolvedResumePositionMs, - resolvedResumeProgressFraction = resolvedResumeProgressFraction, - forceExternal = openExternally, - forceInternal = !openExternally, - ) + val sourceUrl = stream.directPlaybackUrl + if (sourceUrl != null) { + // Persist for Reuse Last Link + if (playerSettings.streamReuseLastLinkEnabled) { + val cacheKey = StreamLinkCacheRepository.contentKey( + type = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) + StreamLinkCacheRepository.save( + contentKey = cacheKey, + url = sourceUrl, + streamName = stream.streamLabel, + addonName = stream.addonName, + addonId = stream.addonId, + requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), + responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + filename = stream.behaviorHints.filename, + videoSize = stream.behaviorHints.videoSize, + bingeGroup = stream.behaviorHints.bingeGroup, + ) + } + val launchId = PlayerLaunchStore.put( + PlayerLaunch( + title = launch.title, + sourceUrl = sourceUrl, + sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), + sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + logo = launch.logo, + poster = launch.poster, + background = launch.background, + seasonNumber = launch.seasonNumber, + episodeNumber = launch.episodeNumber, + episodeTitle = launch.episodeTitle, + episodeThumbnail = launch.episodeThumbnail, + streamTitle = stream.streamLabel, + streamSubtitle = stream.streamSubtitle, + bingeGroup = stream.behaviorHints.bingeGroup, + pauseDescription = pauseDescription, + providerName = stream.addonName, + providerAddonId = stream.addonId, + contentType = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId ?: effectiveVideoId, + parentMetaType = launch.parentMetaType ?: launch.type, + initialPositionMs = resolvedResumePositionMs ?: 0L, + initialProgressFraction = resolvedResumeProgressFraction, + ) + ) + StreamsRepository.cancelLoading() + navController.navigate( + PlayerRoute(launchId = launchId) + ) + } }, onBack = { StreamsRepository.clear() @@ -1722,6 +1630,9 @@ private fun MainAppContent( onPosterClick = { meta -> navController.navigate(DetailRoute(type = meta.type, id = meta.id)) }, + onPosterLongClick = { meta -> + selectedPosterForActions = meta + }, modifier = Modifier.fillMaxSize(), ) } @@ -1766,7 +1677,8 @@ private fun MainAppContent( ?.let(WatchProgressRepository::progressForVideo) ?.takeIf { it.isResumable } - val playerLaunch = PlayerLaunch( + val launchId = PlayerLaunchStore.put( + PlayerLaunch( title = item.title, sourceUrl = sourceUrl, sourceHeaders = emptyMap(), @@ -1788,12 +1700,8 @@ private fun MainAppContent( parentMetaType = item.parentMetaType, initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L, initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f }, + ), ) - if (playerSettingsUiState.externalPlayerEnabled) { - openExternalPlayback(playerLaunch) - return@DownloadsScreen - } - val launchId = PlayerLaunchStore.put(playerLaunch) navController.navigate(PlayerRoute(launchId = launchId)) }, ) @@ -2102,7 +2010,6 @@ private fun rememberGuardedPopBackStack( private fun AppTabHost( selectedTab: AppScreenTab, modifier: Modifier = Modifier, - searchFocusRequestCount: Int = 0, animateHomeCollectionGifs: Boolean = true, onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, @@ -2150,7 +2057,6 @@ private fun AppTabHost( modifier = Modifier.fillMaxSize(), onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, - searchFocusRequestCount = searchFocusRequestCount, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index f58cd2df..861a685f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -44,17 +43,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.NuvioNetworkOfflineCard -import coil3.compose.AsyncImage -import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioBackButton -import com.nuvio.app.core.ui.rememberPosterCardStyleUiState -import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.home.MetaPreview -import com.nuvio.app.features.home.HomeCatalogSettingsRepository -import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.stableKey +import com.nuvio.app.features.home.components.HomePosterCard +import com.nuvio.app.features.watched.WatchedRepository +import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map @@ -72,17 +68,20 @@ fun CatalogScreen( genre: String? = null, onBack: () -> Unit, onPosterClick: ((MetaPreview) -> Unit)? = null, + onPosterLongClick: ((MetaPreview) -> Unit)? = null, modifier: Modifier = Modifier, ) { val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle() - val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() - val posterCardStyle = rememberPosterCardStyleUiState() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() var headerHeightPx by remember { mutableIntStateOf(0) } var observedOfflineState by remember { mutableStateOf(false) } - LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) { + LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) { CatalogRepository.load( manifestUrl = manifestUrl, type = type, @@ -110,7 +109,7 @@ fun CatalogScreen( when (networkStatusUiState.condition) { NetworkCondition.NoInternet, NetworkCondition.ServersUnreachable, - -> { + -> { observedOfflineState = true } @@ -129,7 +128,7 @@ fun CatalogScreen( NetworkCondition.Unknown, NetworkCondition.Checking, - -> Unit + -> Unit } } @@ -156,7 +155,7 @@ fun CatalogScreen( ) { if (uiState.items.isEmpty() && uiState.isLoading) { items(columns * 3) { - CatalogSkeletonTile(cornerRadiusDp = posterCardStyle.cornerRadiusDp) + CatalogSkeletonTile() } } else if (uiState.items.isEmpty()) { item(span = { GridItemSpan(maxLineSpan) }) { @@ -182,11 +181,14 @@ fun CatalogScreen( key = { item -> item.lazyKey }, ) { keyedItem -> val item = keyedItem.value - CatalogPosterTile( + HomePosterCard( item = item, - cornerRadiusDp = posterCardStyle.cornerRadiusDp, - hideLabels = posterCardStyle.hideLabelsEnabled, + isWatched = WatchingState.isPosterWatched( + watchedKeys = watchedUiState.watchedKeys, + item = item, + ), onClick = onPosterClick?.let { { it(item) } }, + onLongClick = onPosterLongClick?.let { { it(item) } }, ) } if (uiState.isLoading) { @@ -252,63 +254,12 @@ private fun CatalogHeader( } @Composable -private fun CatalogPosterTile( - item: MetaPreview, - cornerRadiusDp: Int, - hideLabels: Boolean, - onClick: (() -> Unit)? = null, -) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(item.posterShape.catalogAspectRatio()) - .clip(RoundedCornerShape(cornerRadiusDp.dp)) - .background(MaterialTheme.colorScheme.surface) - .posterCardClickable(onClick = onClick, onLongClick = null), - ) { - if (item.poster != null) { - AsyncImage( - model = item.poster, - contentDescription = item.name, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } - } - if (!hideLabels) { - Text( - text = item.name, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) } - if (detail != null) { - Text( - text = detail, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } else { - Spacer(modifier = Modifier.height(8.dp)) - } - } - } -} - -@Composable -private fun CatalogSkeletonTile(cornerRadiusDp: Int) { +private fun CatalogSkeletonTile() { Box( modifier = Modifier .fillMaxWidth() .aspectRatio(0.68f) - .clip(RoundedCornerShape(cornerRadiusDp.dp)) + .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.surface), ) } @@ -363,13 +314,6 @@ private fun CatalogLoadingFooter() { } } -private fun PosterShape.catalogAspectRatio(): Float = - when (this) { - PosterShape.Poster -> 0.68f - PosterShape.Square -> 1f - PosterShape.Landscape -> 1.2f - } - private fun catalogGridColumnsForWidth(screenWidth: Dp): Int = when { screenWidth >= 1400.dp -> 7