NuvioMobile/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt

3885 lines
193 KiB
Kotlin

package com.nuvio.app
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.CheckCircleOutline
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay
import com.nuvio.app.core.ui.NuvioLoadingIndicator
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.LocalNavAnimatedContentScope
import androidx.navigation3.ui.NavDisplay
import androidx.savedstate.serialization.SavedStateConfiguration
import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory
import coil3.request.CachePolicy
import coil3.request.crossfade
import coil3.svg.SvgDecoder
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.deeplink.AppDeepLink
import com.nuvio.app.core.deeplink.AppDeepLinkRepository
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.sync.AppForegroundMonitor
import com.nuvio.app.core.sync.ProfileSettingsSync
import com.nuvio.app.core.sync.RealtimeSyncConfig
import com.nuvio.app.core.sync.RealtimeSyncInvalidationService
import com.nuvio.app.core.sync.SyncManager
import com.nuvio.app.core.ui.LocalNuvioNavBarScrollState
import com.nuvio.app.core.ui.NuvioNavigationBar
import com.nuvio.app.core.ui.NuvioClassicNavigationBar
import com.nuvio.app.core.ui.NuvioNavBarScrollState
import com.nuvio.app.core.ui.rememberNuvioNavBarScrollState
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.core.ui.NuvioContinueWatchingActionSheet
import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay
import com.nuvio.app.core.ui.PosterZoomAnchor
import com.nuvio.app.core.ui.PosterZoomAnchorHolder
import com.nuvio.app.core.ui.PosterZoomOverlayAction
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.PlatformBackHandler
import com.nuvio.app.core.ui.platformExitApp
import com.nuvio.app.core.ui.configurePlatformImageLoader
import com.nuvio.app.core.ui.NuvioToastHost
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.ui.NuvioFloatingPrompt
import com.nuvio.app.core.ui.ProfileMeshBackground
import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.NuvioTheme
import com.nuvio.app.core.ui.NuvioTokens
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
import com.nuvio.app.core.ui.NativeNavigationTab
import com.nuvio.app.core.ui.NativeProfileSwitcherController
import com.nuvio.app.core.ui.NativeTabBridge
import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported
import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
import com.nuvio.app.core.ui.nuvio
import com.nuvio.app.features.auth.AuthScreen
import com.nuvio.app.features.addons.AddAddonResult
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.catalog.CatalogScreen
import com.nuvio.app.features.catalog.CatalogTarget
import com.nuvio.app.features.cloud.CloudLibraryContentType
import com.nuvio.app.features.cloud.CloudLibraryFile
import com.nuvio.app.features.cloud.CloudLibraryItem
import com.nuvio.app.features.cloud.CloudLibraryPlaybackResult
import com.nuvio.app.features.cloud.CloudLibraryPlaybackTargetLookupResult
import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl
import com.nuvio.app.features.cloud.CloudLibraryRepository
import com.nuvio.app.features.cloud.playbackVideoId
import com.nuvio.app.features.cloud.providerPosterUrl
import com.nuvio.app.features.debrid.DirectDebridPlayableResult
import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
import com.nuvio.app.features.debrid.toastMessage
import com.nuvio.app.features.downloads.DownloadsRepository
import com.nuvio.app.features.downloads.DownloadsScreen
import com.nuvio.app.features.downloads.DownloadItem
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaDetailsScreen
import com.nuvio.app.features.details.MetaPerson
import com.nuvio.app.features.details.PersonDetailScreen
import com.nuvio.app.features.details.TmdbEntityBrowseScreen
import com.nuvio.app.features.tmdb.TmdbEntityKind
import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.HomeScreen
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.LibrarySection
import com.nuvio.app.features.library.LibrarySortOption
import com.nuvio.app.features.library.LibrarySourceMode
import com.nuvio.app.features.library.LibraryScreen
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.library.toMetaPreview
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository
import com.nuvio.app.features.p2p.P2pConsentDialog
import com.nuvio.app.features.p2p.P2pSettingsRepository
import com.nuvio.app.features.player.PlayerLaunch
import com.nuvio.app.features.player.PlayerLaunchStore
import com.nuvio.app.features.player.PlayerScreen
import com.nuvio.app.features.player.PlayerPlaybackSnapshot
import com.nuvio.app.features.player.ExternalPlayerIntentResult
import com.nuvio.app.features.player.ExternalPlayerPlatform
import com.nuvio.app.features.player.ExternalPlayerPlaybackRequest
import com.nuvio.app.features.player.rememberExternalPlayerLauncher
import com.nuvio.app.features.player.prepareExternalPlayerLaunch
import com.nuvio.app.features.player.SubtitleLanguageOption
import com.nuvio.app.features.player.sanitizePlaybackHeaders
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
import com.nuvio.app.features.profiles.AvatarRepository
import com.nuvio.app.features.profiles.NuvioProfile
import com.nuvio.app.features.profiles.ProfileEditScreen
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.profiles.ProfileSelectionScreen
import com.nuvio.app.features.profiles.ProfileSwitcherTab
import com.nuvio.app.features.profiles.parseHexColor
import com.nuvio.app.features.profiles.profileAvatarImageUrl
import com.nuvio.app.features.search.SearchScreen
import com.nuvio.app.features.settings.SettingsScreen
import com.nuvio.app.features.settings.HomescreenSettingsScreen
import com.nuvio.app.features.settings.MetaScreenSettingsScreen
import com.nuvio.app.features.settings.ContinueWatchingSettingsScreen
import com.nuvio.app.features.settings.AddonsSettingsScreen
import com.nuvio.app.features.settings.PluginsSettingsScreen
import com.nuvio.app.features.settings.AccountSettingsScreen
import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen
import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen
import com.nuvio.app.features.settings.NavBarStyle
import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.collection.CollectionManagementScreen
import com.nuvio.app.features.collection.CollectionEditorScreen
import com.nuvio.app.features.collection.CollectionEditorRepository
import com.nuvio.app.features.collection.CollectionEditorPage
import com.nuvio.app.features.collection.CollectionSyncService
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.collection.disposeCollectionEditorPage
import com.nuvio.app.features.collection.FolderDetailScreen
import com.nuvio.app.features.collection.FolderDetailRepository
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
import com.nuvio.app.features.streams.BingeGroupCacheRepository
import com.nuvio.app.features.streams.StreamBehaviorHints
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamLaunch
import com.nuvio.app.features.streams.StreamLaunchStore
import com.nuvio.app.features.streams.StreamLinkCacheRepository
import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.streams.StreamsScreen
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.trakt.TraktScrobbleRepository
import com.nuvio.app.features.updater.AppUpdaterHost
import com.nuvio.app.features.updater.AppUpdaterPlatform
import com.nuvio.app.features.updater.rememberAppUpdaterController
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ResumePromptRepository
import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator
import com.nuvio.app.features.watchprogress.nextUpDismissKey
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
import com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
import com.nuvio.app.navigation.*
import nuvio.composeapp.generated.resources.*
import nuvio.composeapp.generated.resources.app_logo_wordmark
import nuvio.composeapp.generated.resources.compose_catalog_subtitle_library
import nuvio.composeapp.generated.resources.compose_catalog_subtitle_trakt_library
import nuvio.composeapp.generated.resources.compose_nav_home
import nuvio.composeapp.generated.resources.compose_nav_library
import nuvio.composeapp.generated.resources.compose_nav_profile
import nuvio.composeapp.generated.resources.compose_nav_search
import nuvio.composeapp.generated.resources.sidebar_library
import nuvio.composeapp.generated.resources.sidebar_search
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
private val navigationSavedStateConfiguration = SavedStateConfiguration {
serializersModule = SerializersModule {
polymorphic(NavKey::class) {
subclass(TabsRoute::class, TabsRoute.serializer())
subclass(DetailRoute::class, DetailRoute.serializer())
subclass(PersonDetailRoute::class, PersonDetailRoute.serializer())
subclass(EntityBrowseRoute::class, EntityBrowseRoute.serializer())
subclass(SettingsPageRoute::class, SettingsPageRoute.serializer())
subclass(HomescreenSettingsRoute::class, HomescreenSettingsRoute.serializer())
subclass(MetaScreenSettingsRoute::class, MetaScreenSettingsRoute.serializer())
subclass(ContinueWatchingSettingsRoute::class, ContinueWatchingSettingsRoute.serializer())
subclass(DownloadsSettingsRoute::class, DownloadsSettingsRoute.serializer())
subclass(DownloadShowRoute::class, DownloadShowRoute.serializer())
subclass(AddonsSettingsRoute::class, AddonsSettingsRoute.serializer())
subclass(PluginsSettingsRoute::class, PluginsSettingsRoute.serializer())
subclass(AccountSettingsRoute::class, AccountSettingsRoute.serializer())
subclass(SupportersContributorsSettingsRoute::class, SupportersContributorsSettingsRoute.serializer())
subclass(LicensesAttributionsSettingsRoute::class, LicensesAttributionsSettingsRoute.serializer())
subclass(CollectionsRoute::class, CollectionsRoute.serializer())
subclass(CollectionEditorRoute::class, CollectionEditorRoute.serializer())
subclass(CollectionEditorPageRoute::class, CollectionEditorPageRoute.serializer())
subclass(FolderDetailRoute::class, FolderDetailRoute.serializer())
subclass(StreamRoute::class, StreamRoute.serializer())
subclass(CatalogRoute::class, CatalogRoute.serializer())
subclass(PlayerRoute::class, PlayerRoute.serializer())
}
}
}
private data class PendingP2pStreamOpen(
val stream: StreamItem,
val resumePositionMs: Long?,
val resumeProgressFraction: Float?,
val forceExternal: Boolean,
val forceInternal: Boolean,
val isAutoPlay: Boolean,
)
private data class CatalogLaunch(
val title: String,
val subtitle: String,
val target: CatalogTarget,
)
private object CatalogLaunchStore {
private var nextLaunchId = 1L
private val launches = mutableMapOf<Long, CatalogLaunch>()
fun put(launch: CatalogLaunch): Long {
val launchId = nextLaunchId++
launches[launchId] = launch
return launchId
}
fun get(launchId: Long): CatalogLaunch? = launches[launchId]
fun remove(launchId: Long) {
launches.remove(launchId)
}
}
/** Idempotent cleanup used by both Navigation 3 and SwiftUI interactive-pop handling. */
fun disposeRoute(route: AppRoute) {
when (route) {
is StreamRoute -> {
StreamsRepository.clear()
StreamLaunchStore.remove(route.launchId)
}
is PlayerRoute -> {
ResumePromptRepository.markPlayerExitedNormally()
PlayerLaunchStore.remove(route.launchId)
}
is CatalogRoute -> {
CatalogRepository.clear()
CatalogLaunchStore.remove(route.launchId)
}
is CollectionEditorRoute -> CollectionEditorRepository.clear()
is CollectionEditorPageRoute -> {
runCatching { CollectionEditorPage.valueOf(route.pageName) }
.getOrNull()
?.let(::disposeCollectionEditorPage)
}
is FolderDetailRoute -> FolderDetailRepository.clear()
else -> Unit
}
}
private data class PosterActionTarget(
val preview: MetaPreview,
val libraryItem: LibraryItem? = null,
val libraryListKey: String? = null,
)
enum class AppScreenTab {
Home,
Search,
Library,
Settings,
;
companion object {
fun fromName(name: String): AppScreenTab =
entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: Home
}
}
private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) {
AppScreenTab.Home -> NativeNavigationTab.Home
AppScreenTab.Search -> NativeNavigationTab.Search
AppScreenTab.Library -> NativeNavigationTab.Library
AppScreenTab.Settings -> NativeNavigationTab.Settings
}
private fun NativeNavigationTab.toAppScreenTab(): AppScreenTab = when (this) {
NativeNavigationTab.Home -> AppScreenTab.Home
NativeNavigationTab.Search -> AppScreenTab.Search
NativeNavigationTab.Library -> AppScreenTab.Library
NativeNavigationTab.Settings -> AppScreenTab.Settings
}
private fun PlayerLaunch.toExternalPlayerPlaybackRequest(): ExternalPlayerPlaybackRequest =
ExternalPlayerPlaybackRequest(
sourceUrl = sourceUrl,
title = title,
streamTitle = streamTitle,
sourceHeaders = sourceHeaders,
resumePositionMs = initialPositionMs,
season = seasonNumber,
episode = episodeNumber,
episodeTitle = episodeTitle,
)
private enum class AppGateScreen {
Loading,
Auth,
ProfileSelection,
ProfileEdit,
Main,
}
private object NativeAppGateRequests {
val profileSelection = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
fun requestProfileSelection() {
profileSelection.tryEmit(Unit)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Preview
fun App(
initialTab: AppScreenTab = AppScreenTab.Home,
initialRoute: AppRoute = TabsRoute,
useNativeNavigation: Boolean = false,
useNativeTabBar: Boolean = false,
useTabletFloatingTabBar: Boolean = false,
ownsAppRuntime: Boolean = true,
bypassAppGate: Boolean = false,
onNavigate: ((AppRoute, launchSingleTop: Boolean) -> Unit)? = null,
onGoBack: (() -> Unit)? = null,
onReplace: ((AppRoute) -> Unit)? = null,
onActivate: ((AppScreenTab) -> Unit)? = null,
onAppReady: ((Boolean) -> Unit)? = null,
onTabTitles: ((home: String, search: String, library: String, profile: String, switchProfile: String, addProfile: String) -> Unit)? = null,
nativeProfileSwitcherController: NativeProfileSwitcherController? = null,
) {
setSingletonImageLoaderFactory { context ->
ImageLoader.Builder(context)
.crossfade(true)
.diskCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.components {
add(SvgDecoder.Factory())
}
.configurePlatformImageLoader()
.build()
}
val selectedTheme by remember {
ThemeSettingsRepository.ensureLoaded()
ThemeSettingsRepository.selectedTheme
}.collectAsStateWithLifecycle()
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle()
NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) {
if (bypassAppGate) {
MainAppContent(
initialTab = initialTab,
initialRoute = initialRoute,
useNativeNavigation = useNativeNavigation,
useNativeTabBar = useNativeTabBar,
useTabletFloatingTabBar = useTabletFloatingTabBar,
ownsAppRuntime = false,
onNavigate = onNavigate,
onGoBack = onGoBack,
onReplace = onReplace,
onActivate = onActivate,
onTabTitles = onTabTitles,
nativeProfileSwitcherController = nativeProfileSwitcherController,
onSwitchProfile = {
onActivate?.invoke(AppScreenTab.Home)
NativeAppGateRequests.requestProfileSelection()
},
)
return@NuvioTheme
}
LaunchedEffect(Unit) {
if (!ownsAppRuntime) return@LaunchedEffect
AuthRepository.initialize()
}
LaunchedEffect(Unit) {
if (!ownsAppRuntime) return@LaunchedEffect
NetworkStatusRepository.ensureStarted()
ProfileRepository.loadCachedProfiles()
AvatarRepository.fetchAvatars()
}
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val profileAvatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
val networkStatusUiState by remember {
NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle()
LaunchedEffect(
profileState.activeProfile?.profileIndex,
profileState.activeProfile?.name,
profileState.activeProfile?.avatarColorHex,
profileState.activeProfile?.avatarId,
profileState.activeProfile?.avatarUrl,
profileAvatars,
) {
val activeProfile = profileState.activeProfile
val avatarItem = activeProfile?.avatarId?.let { avatarId ->
profileAvatars.find { it.id == avatarId }
}
NativeTabBridge.publishProfileTabIcon(
name = activeProfile?.name,
avatarColorHex = activeProfile?.avatarColorHex,
avatarImageUrl = activeProfile?.let { profileAvatarImageUrl(it, avatarItem) },
avatarBackgroundColorHex = avatarItem?.bgColor,
)
}
var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) }
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
var isNewProfile by remember { mutableStateOf(false) }
var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(gateScreen, onAppReady) {
if (gateScreen != AppGateScreen.Main.name) {
onAppReady?.invoke(false)
}
}
LaunchedEffect(useNativeNavigation, ownsAppRuntime) {
if (!useNativeNavigation || !ownsAppRuntime) return@LaunchedEffect
NativeAppGateRequests.profileSelection.collect {
autoSkipProfileSelection = false
gateScreen = AppGateScreen.ProfileSelection.name
}
}
fun rememberedStartupProfile(profiles: List<NuvioProfile>): NuvioProfile? {
val currentProfileState = ProfileRepository.state.value
if (
!currentProfileState.rememberLastProfileEnabled ||
!currentProfileState.hasEverSelectedProfile
) {
return null
}
return profiles
.find { it.profileIndex == ProfileRepository.activeProfileId }
?.takeUnless { it.pinEnabled }
}
fun enterProfileGate(profiles: List<NuvioProfile>, syncOnEnter: Boolean) {
if (profiles.isEmpty()) {
autoSkipProfileSelection = true
gateScreen = AppGateScreen.ProfileSelection.name
return
}
rememberedStartupProfile(profiles)?.let { profile ->
ProfileRepository.selectProfile(profile.profileIndex)
if (syncOnEnter) {
SyncManager.pullAllForProfile(profile.profileIndex)
}
gateScreen = AppGateScreen.Main.name
autoSkipProfileSelection = false
return
}
autoSkipProfileSelection = true
if (profiles.size == 1) {
val onlyProfile = profiles.first()
if (onlyProfile.pinEnabled) {
gateScreen = AppGateScreen.ProfileSelection.name
return
}
ProfileRepository.selectProfile(onlyProfile.profileIndex)
if (syncOnEnter) {
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
}
gateScreen = AppGateScreen.Main.name
autoSkipProfileSelection = false
} else {
gateScreen = AppGateScreen.ProfileSelection.name
}
}
LaunchedEffect(authState, networkStatusUiState.condition, profileState.profiles) {
val cachedProfiles = profileState.profiles
val hasCachedProfileAccess =
cachedProfiles.isNotEmpty() &&
authState !is AuthState.Authenticated
val allowCachedProfileAccess =
hasCachedProfileAccess &&
(
networkStatusUiState.condition != NetworkCondition.Online ||
gateScreen != AppGateScreen.Auth.name
)
when (authState) {
is AuthState.Loading -> {
if (hasCachedProfileAccess) {
enterProfileGate(cachedProfiles, syncOnEnter = false)
} else {
gateScreen = AppGateScreen.Loading.name
}
}
is AuthState.Unauthenticated -> {
if (allowCachedProfileAccess) {
enterProfileGate(cachedProfiles, syncOnEnter = false)
} else {
ProfileRepository.clearInMemory()
gateScreen = AppGateScreen.Auth.name
}
}
is AuthState.Authenticated -> {
val authenticatedState = authState as AuthState.Authenticated
ProfileRepository.ensureLoaded(authenticatedState.userId)
if (gateScreen == AppGateScreen.Loading.name || gateScreen == AppGateScreen.Auth.name) {
enterProfileGate(ProfileRepository.state.value.profiles, syncOnEnter = true)
}
}
}
}
LaunchedEffect((authState as? AuthState.Authenticated)?.userId) {
val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect
ProfileRepository.ensureLoaded(authenticatedState.userId)
ProfileRepository.pullProfiles()
}
LaunchedEffect(
gateScreen,
autoSkipProfileSelection,
profileState.profiles,
profileState.hasEverSelectedProfile,
profileState.rememberLastProfileEnabled,
profileState.activeProfile?.profileIndex,
profileState.activeProfile?.pinEnabled,
) {
if (
autoSkipProfileSelection &&
gateScreen == AppGateScreen.ProfileSelection.name
) {
rememberedStartupProfile(profileState.profiles)?.let { profile ->
ProfileRepository.selectProfile(profile.profileIndex)
SyncManager.pullAllForProfile(profile.profileIndex)
gateScreen = AppGateScreen.Main.name
autoSkipProfileSelection = false
return@LaunchedEffect
}
if (profileState.profiles.size != 1) return@LaunchedEffect
val onlyProfile = profileState.profiles.first()
if (onlyProfile.pinEnabled) return@LaunchedEffect
ProfileRepository.selectProfile(onlyProfile.profileIndex)
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
gateScreen = AppGateScreen.Main.name
autoSkipProfileSelection = false
}
}
AnimatedContent(
targetState = gateScreen,
label = "app_gate",
transitionSpec = {
(fadeIn(tween(400)) + scaleIn(tween(400), initialScale = 0.94f))
.togetherWith(fadeOut(tween(250)))
},
) { currentGate ->
when (currentGate) {
AppGateScreen.Loading.name -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.nuvio.colors.background),
contentAlignment = Alignment.Center,
) {
NuvioLoadingIndicator(color = MaterialTheme.nuvio.colors.accent)
}
}
AppGateScreen.Auth.name -> {
AuthScreen(modifier = Modifier.fillMaxSize())
}
AppGateScreen.ProfileSelection.name -> {
PlatformBackHandler(enabled = gateScreen == AppGateScreen.ProfileSelection.name) {
if (!autoSkipProfileSelection) {
gateScreen = AppGateScreen.Main.name
}
}
ProfileSelectionScreen(
onProfileSelected = { profile ->
ProfileRepository.selectProfile(profile.profileIndex)
if (authState is AuthState.Authenticated) {
SyncManager.pullAllForProfile(profile.profileIndex)
}
gateScreen = AppGateScreen.Main.name
},
onEditProfile = { profile ->
editingProfile = profile
isNewProfile = false
gateScreen = AppGateScreen.ProfileEdit.name
},
onAddProfile = {
editingProfile = null
isNewProfile = true
gateScreen = AppGateScreen.ProfileEdit.name
},
modifier = Modifier.fillMaxSize(),
)
}
AppGateScreen.ProfileEdit.name -> {
PlatformBackHandler(enabled = gateScreen == AppGateScreen.ProfileEdit.name) {
gateScreen = AppGateScreen.ProfileSelection.name
}
ProfileEditScreen(
profile = editingProfile,
onBack = { gateScreen = AppGateScreen.ProfileSelection.name },
onSaved = { gateScreen = AppGateScreen.ProfileSelection.name },
modifier = Modifier.fillMaxSize(),
)
}
AppGateScreen.Main.name -> {
MainAppContent(
initialTab = initialTab,
initialRoute = initialRoute,
useNativeNavigation = useNativeNavigation,
useNativeTabBar = useNativeTabBar,
useTabletFloatingTabBar = useTabletFloatingTabBar,
ownsAppRuntime = ownsAppRuntime,
onNavigate = onNavigate,
onGoBack = onGoBack,
onReplace = onReplace,
onActivate = onActivate,
onTabTitles = onTabTitles,
nativeProfileSwitcherController = nativeProfileSwitcherController,
onRootContentReady = { ready ->
onAppReady?.invoke(
ready && gateScreen == AppGateScreen.Main.name,
)
},
onSwitchProfile = {
autoSkipProfileSelection = false
gateScreen = AppGateScreen.ProfileSelection.name
},
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable
private fun MainAppContent(
initialTab: AppScreenTab = AppScreenTab.Home,
initialRoute: AppRoute = TabsRoute,
useNativeNavigation: Boolean = false,
useNativeTabBar: Boolean = false,
useTabletFloatingTabBar: Boolean = false,
ownsAppRuntime: Boolean = true,
onNavigate: ((AppRoute, launchSingleTop: Boolean) -> Unit)? = null,
onGoBack: (() -> Unit)? = null,
onReplace: ((AppRoute) -> Unit)? = null,
onActivate: ((AppScreenTab) -> Unit)? = null,
onTabTitles: ((home: String, search: String, library: String, profile: String, switchProfile: String, addProfile: String) -> Unit)? = null,
nativeProfileSwitcherController: NativeProfileSwitcherController? = null,
onRootContentReady: ((Boolean) -> Unit)? = null,
onSwitchProfile: () -> Unit = {},
) {
val navBackStack = rememberNavBackStack(navigationSavedStateConfiguration, initialRoute)
val navController = remember(navBackStack, onNavigate, onGoBack, onReplace) {
NuvioNavigator(
backStack = navBackStack,
onExternalNavigate = onNavigate,
onExternalBack = onGoBack,
onExternalReplace = onReplace,
onRouteRemoved = ::disposeRoute,
)
}
val appUpdaterController = rememberAppUpdaterController()
if (ownsAppRuntime) {
remember {
EpisodeReleaseNotificationsRepository.ensureLoaded()
}
remember {
CollectionSyncService.startObserving()
}
remember {
ProfileSettingsSync.startObserving()
}
}
val hapticFeedback = LocalHapticFeedback.current
val focusManager = LocalFocusManager.current
val uriHandler = LocalUriHandler.current
val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable(initialTab) { mutableStateOf(initialTab) }
var searchFocusRequestCount by remember { mutableStateOf(0) }
val homeScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val searchScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val libraryScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val settingsRootActionRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val currentRoute = navBackStack.lastOrNull() as? AppRoute
val liquidGlassNativeTabBarEnabled by remember {
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
}.collectAsStateWithLifecycle()
val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() }
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
var selectedPosterActionTarget by remember { mutableStateOf<PosterActionTarget?>(null) }
var selectedPosterAnchor by remember { mutableStateOf<PosterZoomAnchor?>(null) }
val posterOverlayHazeState = rememberHazeState()
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
var selectedContinueWatchingZoomAnchor by remember { mutableStateOf<PosterZoomAnchor?>(null) }
var requestedSettingsPageName by rememberSaveable { mutableStateOf<String?>(null) }
var showLibraryListPicker by remember { mutableStateOf(false) }
var pickerItem by remember { mutableStateOf<LibraryItem?>(null) }
var pickerTitle by remember { mutableStateOf("") }
var pickerTabs by remember { mutableStateOf<List<TraktListTab>>(emptyList()) }
var pickerMembership by remember { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
var pickerPending by remember { mutableStateOf(false) }
var pickerError by remember { mutableStateOf<String?>(null) }
val addonsUiState by remember {
AddonRepository.initialize()
AddonRepository.uiState
}.collectAsStateWithLifecycle()
val libraryUiState by remember {
LibraryRepository.ensureLoaded()
LibraryRepository.uiState
}.collectAsStateWithLifecycle()
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val openPosterActions: (PosterActionTarget) -> Unit = { target ->
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
focusManager.clearFocus(force = true)
selectedPosterAnchor = PosterZoomAnchorHolder.consume()
coroutineScope.launch {
withFrameNanos { }
selectedPosterActionTarget = target
}
}
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val launchOverlayProfileColor = remember(profileState.activeProfile, profileState.profiles) {
val sourceProfile = profileState.activeProfile ?: profileState.profiles.firstOrNull()
sourceProfile?.avatarColorHex?.let(::parseHexColor) ?: Color(0xFF1E88E5)
}
val playerSettingsUiState by remember {
PlayerSettingsRepository.ensureLoaded()
PlayerSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val p2pSettingsUiState by remember {
P2pSettingsRepository.ensureLoaded()
P2pSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val watchedUiState by remember {
WatchedRepository.ensureLoaded()
WatchedRepository.uiState
}.collectAsStateWithLifecycle()
val downloadsUiState by remember {
DownloadsRepository.ensureLoaded()
DownloadsRepository.uiState
}.collectAsStateWithLifecycle()
val networkStatusUiState by remember {
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 failedOpenBrowserText = stringResource(Res.string.settings_trakt_failed_open_browser)
val cloudLibraryPlayFailedText = stringResource(Res.string.cloud_library_play_failed)
val cloudLibraryPlayDisabledText = stringResource(Res.string.cloud_library_play_disabled)
val cloudLibraryPlayNotConnectedText = stringResource(Res.string.cloud_library_play_not_connected)
val nativeTabHomeTitle = stringResource(Res.string.compose_nav_home)
val nativeTabSearchTitle = stringResource(Res.string.compose_nav_search)
val nativeTabLibraryTitle = stringResource(Res.string.compose_nav_library)
val nativeTabProfileTitle = stringResource(Res.string.compose_nav_profile)
val nativeSwitchProfileTitle = stringResource(Res.string.compose_settings_root_switch_profile_title)
val nativeAddProfileTitle = stringResource(Res.string.compose_profile_add_profile)
val homescreenSettingsTitle = stringResource(Res.string.compose_settings_page_homescreen)
val metaScreenSettingsTitle = stringResource(Res.string.compose_settings_page_meta_screen)
val continueWatchingSettingsTitle = stringResource(Res.string.compose_settings_page_continue_watching)
val debridSettingsTitle = stringResource(Res.string.compose_settings_page_debrid)
val downloadsSettingsTitle = stringResource(Res.string.compose_settings_root_downloads_title)
val addonsSettingsTitle = stringResource(Res.string.compose_settings_page_addons)
val pluginsSettingsTitle = stringResource(Res.string.compose_settings_page_plugins)
val accountSettingsTitle = stringResource(Res.string.compose_settings_page_account)
val supportersSettingsTitle = stringResource(Res.string.compose_settings_page_supporters_contributors)
val licensesSettingsTitle = stringResource(Res.string.compose_settings_page_licenses_attributions)
val collectionsTitle = stringResource(Res.string.collections_header)
val newCollectionTitle = stringResource(Res.string.collections_new)
val detailsFallbackTitle = stringResource(Res.string.meta_section_details_title)
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
var initialHomeReady by rememberSaveable(ownsAppRuntime) {
mutableStateOf(!ownsAppRuntime)
}
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) }
var lastNetworkToastCondition by rememberSaveable { mutableStateOf(NetworkCondition.Unknown.name) }
var watchSourceReconnectPending by remember { mutableStateOf(false) }
fun activateTab(tab: AppScreenTab) {
if (useNativeNavigation && onActivate != null) {
onActivate(tab)
} else {
selectedTab = tab
}
}
fun handleRootTabClick(tab: AppScreenTab) {
if (selectedTab != tab) {
activateTab(tab)
return
}
when (tab) {
AppScreenTab.Home -> homeScrollToTopRequests.tryEmit(Unit)
AppScreenTab.Search -> {
searchFocusRequestCount++
searchScrollToTopRequests.tryEmit(Unit)
}
AppScreenTab.Library -> libraryScrollToTopRequests.tryEmit(Unit)
AppScreenTab.Settings -> settingsRootActionRequests.tryEmit(Unit)
}
}
LaunchedEffect(
liquidGlassNativeTabBarSupported,
liquidGlassNativeTabBarEnabled,
useNativeNavigation,
currentRoute,
selectedTab,
) {
NativeTabBridge.requestedTabs.collectLatest { requestedTab ->
val requestedAppTab = requestedTab.toAppScreenTab()
if (
useNativeNavigation &&
currentRoute is TabsRoute &&
requestedAppTab == selectedTab
) {
handleRootTabClick(requestedAppTab)
} else if (
!useNativeNavigation &&
liquidGlassNativeTabBarSupported &&
liquidGlassNativeTabBarEnabled
) {
handleRootTabClick(requestedAppTab)
}
}
}
LaunchedEffect(
nativeTabHomeTitle,
nativeTabSearchTitle,
nativeTabLibraryTitle,
nativeTabProfileTitle,
nativeSwitchProfileTitle,
nativeAddProfileTitle,
onTabTitles,
) {
NativeTabBridge.publishTabTitles(
home = nativeTabHomeTitle,
search = nativeTabSearchTitle,
library = nativeTabLibraryTitle,
profile = nativeTabProfileTitle,
)
onTabTitles?.invoke(
nativeTabHomeTitle,
nativeTabSearchTitle,
nativeTabLibraryTitle,
nativeTabProfileTitle,
nativeSwitchProfileTitle,
nativeAddProfileTitle,
)
}
LaunchedEffect(selectedTab) {
NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab())
if (selectedTab != AppScreenTab.Search) {
searchFocusRequestCount = 0
}
}
var profileSwitchLoading by remember { mutableStateOf(false) }
LaunchedEffect(nativeProfileSwitcherController, ownsAppRuntime) {
if (!ownsAppRuntime) return@LaunchedEffect
nativeProfileSwitcherController?.selectedProfileIndices?.collectLatest { profileIndex ->
val profile = ProfileRepository.state.value.profiles
.firstOrNull { it.profileIndex == profileIndex }
?: return@collectLatest
profileSwitchLoading = true
activateTab(AppScreenTab.Home)
ProfileRepository.selectProfile(profile.profileIndex)
SyncManager.pullAllForProfile(profile.profileIndex)
}
}
LaunchedEffect(nativeProfileSwitcherController, ownsAppRuntime, onSwitchProfile) {
if (!ownsAppRuntime) return@LaunchedEffect
nativeProfileSwitcherController?.requestedManageProfiles?.collectLatest {
activateTab(AppScreenTab.Home)
onSwitchProfile()
}
}
val launchOverlayState = remember(ownsAppRuntime) {
MutableTransitionState(
ownsAppRuntime && (!initialHomeReady || profileSwitchLoading),
)
}
launchOverlayState.targetState =
ownsAppRuntime && (!initialHomeReady || profileSwitchLoading)
LaunchedEffect(
launchOverlayState.targetState,
ownsAppRuntime,
onRootContentReady,
) {
if (ownsAppRuntime) {
onRootContentReady?.invoke(!launchOverlayState.targetState)
}
}
LaunchedEffect(
currentRoute,
liquidGlassNativeTabBarSupported,
liquidGlassNativeTabBarEnabled,
initialHomeReady,
profileSwitchLoading,
useNativeNavigation,
) {
val visible = !useNativeNavigation &&
liquidGlassNativeTabBarSupported &&
liquidGlassNativeTabBarEnabled &&
initialHomeReady &&
!profileSwitchLoading &&
currentRoute is TabsRoute
NativeTabBridge.publishTabBarVisible(visible)
}
DisposableEffect(Unit) {
onDispose {
NativeTabBridge.publishTabBarVisible(false)
}
}
LaunchedEffect(Unit) {
if (!ownsAppRuntime) return@LaunchedEffect
NetworkStatusRepository.ensureStarted()
EpisodeReleaseNotificationsRepository.refreshAsync()
kotlinx.coroutines.delay(5_000)
initialHomeReady = true
}
LaunchedEffect(Unit) {
if (!ownsAppRuntime) return@LaunchedEffect
AppForegroundMonitor.events().collect {
NetworkStatusRepository.requestForegroundRefresh()
}
}
LaunchedEffect(networkStatusUiState.condition) {
if (!ownsAppRuntime) return@LaunchedEffect
val condition = networkStatusUiState.condition
if (!networkToastBaselineReady) {
networkToastBaselineReady = true
lastNetworkToastCondition = condition.name
return@LaunchedEffect
}
val previousConditionName = lastNetworkToastCondition
if (previousConditionName == condition.name) return@LaunchedEffect
when (condition) {
NetworkCondition.NoInternet -> {
NuvioToastController.show(getString(Res.string.network_no_internet_connection))
}
NetworkCondition.ServersUnreachable -> {
NuvioToastController.show(getString(Res.string.network_cannot_reach_servers))
}
NetworkCondition.Online -> {
if (
previousConditionName == NetworkCondition.NoInternet.name ||
previousConditionName == NetworkCondition.ServersUnreachable.name
) {
NuvioToastController.show(getString(Res.string.network_back_online))
}
}
NetworkCondition.Unknown,
NetworkCondition.Checking,
-> Unit
}
lastNetworkToastCondition = condition.name
}
LaunchedEffect(
networkStatusUiState.condition,
(authState as? AuthState.Authenticated)?.userId,
profileState.activeProfile?.profileIndex,
) {
if (!ownsAppRuntime) return@LaunchedEffect
when (networkStatusUiState.condition) {
NetworkCondition.NoInternet,
NetworkCondition.ServersUnreachable,
-> watchSourceReconnectPending = true
NetworkCondition.Online -> {
if (!watchSourceReconnectPending) return@LaunchedEffect
val profileId = profileState.activeProfile?.profileIndex
?: ProfileRepository.activeProfileId
val authenticatedState = authState as? AuthState.Authenticated
if (authenticatedState != null && !authenticatedState.isAnonymous) {
SyncManager.requestForegroundPull(profileId = profileId, force = true)
watchSourceReconnectPending = false
} else {
val result = WatchProgressSourceCoordinator.refreshActiveSource(
profileId = profileId,
force = true,
)
if (result.succeeded) {
watchSourceReconnectPending = false
}
}
}
NetworkCondition.Unknown,
NetworkCondition.Checking,
-> Unit
}
}
LaunchedEffect(
initialHomeReady,
offlineLaunchRouteHandled,
networkStatusUiState.condition,
downloadsUiState.completedItems,
) {
if (!ownsAppRuntime) return@LaunchedEffect
if (!initialHomeReady || offlineLaunchRouteHandled) return@LaunchedEffect
when (networkStatusUiState.condition) {
NetworkCondition.Unknown,
NetworkCondition.Checking,
-> return@LaunchedEffect
NetworkCondition.Online -> {
offlineLaunchRouteHandled = true
}
NetworkCondition.NoInternet,
NetworkCondition.ServersUnreachable,
-> {
offlineLaunchRouteHandled = true
val hasPlayableDownload = downloadsUiState.completedItems.any {
DownloadsRepository.playableLocalFileUri(it) != null
}
if (hasPlayableDownload) {
activateTab(AppScreenTab.Settings)
navController.navigate(DownloadsSettingsRoute(downloadsSettingsTitle)) {
launchSingleTop = true
}
}
}
}
}
LaunchedEffect(authState, profileState.activeProfile?.profileIndex) {
if (!ownsAppRuntime) return@LaunchedEffect
if (!RealtimeSyncConfig.ENABLED) {
RealtimeSyncInvalidationService.stop()
return@LaunchedEffect
}
val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect
if (authenticatedState.isAnonymous) return@LaunchedEffect
val activeProfileId = profileState.activeProfile?.profileIndex ?: return@LaunchedEffect
RealtimeSyncInvalidationService.start(
userId = authenticatedState.userId,
profileId = activeProfileId,
)
}
DisposableEffect(authState, profileState.activeProfile?.profileIndex) {
val authenticatedState = authState as? AuthState.Authenticated
if (ownsAppRuntime && (
!RealtimeSyncConfig.ENABLED ||
authenticatedState == null ||
authenticatedState.isAnonymous ||
profileState.activeProfile == null
)) {
RealtimeSyncInvalidationService.stop()
}
onDispose {
if (ownsAppRuntime) RealtimeSyncInvalidationService.stop()
}
}
DisposableEffect(authState, profileState.activeProfile?.profileIndex) {
val authenticatedState = authState as? AuthState.Authenticated
val activeProfileId = profileState.activeProfile?.profileIndex
if (ownsAppRuntime && authenticatedState != null && !authenticatedState.isAnonymous && activeProfileId != null) {
SyncManager.startPeriodicNuvioSyncPull(activeProfileId)
} else if (ownsAppRuntime) {
SyncManager.stopPeriodicNuvioSyncPull()
}
onDispose {
if (ownsAppRuntime) SyncManager.stopPeriodicNuvioSyncPull()
}
}
LaunchedEffect(authState, profileState.activeProfile?.profileIndex) {
if (!ownsAppRuntime) return@LaunchedEffect
val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect
if (authenticatedState.isAnonymous) return@LaunchedEffect
val activeProfileId = profileState.activeProfile?.profileIndex ?: return@LaunchedEffect
SyncManager.pullAllForProfile(activeProfileId)
AppForegroundMonitor.events().collect {
SyncManager.requestForegroundPull(activeProfileId, force = true)
}
}
var resumePromptItem by remember { mutableStateOf<ContinueWatchingItem?>(null) }
var lastExternalPlayerLaunch by remember { mutableStateOf<PlayerLaunch?>(null) }
val activePlaybackProfileId = profileState.activeProfile?.profileIndex ?: ProfileRepository.activeProfileId
val launchExternalPlayer = rememberExternalPlayerLauncher { result ->
if (result != null && result.positionMs > 0L) {
coroutineScope.launch {
val durationMs = result.durationMs
val progressPercent = if (durationMs != null && durationMs > 0L) {
(result.positionMs.toFloat() / durationMs.toFloat() * 100f).coerceIn(0f, 100f)
} else {
null
}
val playerLaunch = lastExternalPlayerLaunch
if (TraktAuthRepository.isAuthenticated.value && progressPercent != null && playerLaunch != null) {
val scrobbleItem = TraktScrobbleRepository.buildItem(
contentType = playerLaunch.parentMetaType,
parentMetaId = playerLaunch.parentMetaId,
videoId = playerLaunch.videoId,
title = playerLaunch.title,
seasonNumber = playerLaunch.seasonNumber,
episodeNumber = playerLaunch.episodeNumber,
episodeTitle = playerLaunch.episodeTitle,
)
if (scrobbleItem != null) {
runCatching {
TraktScrobbleRepository.scrobbleStop(
profileId = playerLaunch.profileId,
item = scrobbleItem,
progressPercent = progressPercent,
)
}
}
}
playerLaunch?.let { playerLaunch ->
val session = WatchProgressPlaybackSession(
profileId = playerLaunch.profileId,
contentType = playerLaunch.contentType ?: playerLaunch.parentMetaType,
parentMetaId = playerLaunch.parentMetaId,
parentMetaType = playerLaunch.parentMetaType,
videoId = playerLaunch.videoId ?: playerLaunch.parentMetaId,
title = playerLaunch.title,
logo = playerLaunch.logo,
poster = playerLaunch.poster,
background = playerLaunch.background,
seasonNumber = playerLaunch.seasonNumber,
episodeNumber = playerLaunch.episodeNumber,
episodeTitle = playerLaunch.episodeTitle,
episodeThumbnail = playerLaunch.episodeThumbnail,
providerName = playerLaunch.providerName,
providerAddonId = playerLaunch.providerAddonId,
lastStreamTitle = playerLaunch.streamTitle,
lastSourceUrl = playerLaunch.sourceUrl,
)
val snapshot = PlayerPlaybackSnapshot(
isLoading = false,
isPlaying = false,
isEnded = !result.endedByUser,
durationMs = durationMs ?: 0L,
positionMs = result.positionMs,
)
WatchProgressRepository.upsertPlaybackProgress(
session = session,
snapshot = snapshot,
)
}
}
}
}
val continueWatchingPreferencesUiState by remember {
ContinueWatchingPreferencesRepository.ensureLoaded()
ContinueWatchingPreferencesRepository.uiState
}.collectAsStateWithLifecycle()
LaunchedEffect(
initialHomeReady,
profileSwitchLoading,
profileState.activeProfile?.profileIndex,
continueWatchingPreferencesUiState.showResumePromptOnLaunch,
) {
if (!ownsAppRuntime) return@LaunchedEffect
if (!initialHomeReady || profileSwitchLoading) return@LaunchedEffect
if (resumePromptItem != null) return@LaunchedEffect
if (continueWatchingPreferencesUiState.showResumePromptOnLaunch) {
resumePromptItem = ResumePromptRepository.consumeResumePrompt()
}
}
LaunchedEffect(currentRoute) {
val inPlaybackFlow = currentRoute is StreamRoute || currentRoute is PlayerRoute
if (inPlaybackFlow) {
resumePromptItem = null
}
}
LaunchedEffect(navController) {
if (!ownsAppRuntime) return@LaunchedEffect
AppDeepLinkRepository.pendingDeepLink.collectLatest { deepLink ->
when (deepLink) {
is AppDeepLink.Meta -> {
activateTab(AppScreenTab.Home)
val routeTitle = runCatching {
MetaDetailsRepository.fetch(deepLink.type, deepLink.id)?.name
}.getOrNull().orEmpty().ifBlank { detailsFallbackTitle }
navController.navigate(
DetailRoute(
type = deepLink.type,
id = deepLink.id,
title = routeTitle,
)
) {
launchSingleTop = true
}
AppDeepLinkRepository.markConsumed(deepLink)
}
is AppDeepLink.AddonInstall -> {
activateTab(AppScreenTab.Settings)
navController.navigate(AddonsSettingsRoute(addonsSettingsTitle)) {
launchSingleTop = true
}
NuvioToastController.show(getString(Res.string.addons_modal_checking_title))
AddonRepository.initialize()
when (val result = AddonRepository.addAddon(deepLink.manifestUrl)) {
is AddAddonResult.Success -> {
NuvioToastController.show(
getString(Res.string.addons_modal_success_message, result.manifest.name),
)
}
is AddAddonResult.Error -> {
NuvioToastController.show(result.message)
}
}
AppDeepLinkRepository.markConsumed(deepLink)
}
AppDeepLink.Downloads -> {
activateTab(AppScreenTab.Settings)
navController.navigate(DownloadsSettingsRoute(downloadsSettingsTitle)) {
launchSingleTop = true
}
AppDeepLinkRepository.markConsumed(deepLink)
}
null -> Unit
}
}
}
suspend fun openExternalPlayback(launch: PlayerLaunch): Boolean {
lastExternalPlayerLaunch = launch
// Persist binge group for subsequent episode plays (same as internal player)
val bingeGroup = launch.bingeGroup
if (bingeGroup != null && launch.parentMetaId.isNotBlank()) {
BingeGroupCacheRepository.save(launch.parentMetaId, bingeGroup)
}
val baseRequest = launch.toExternalPlayerPlaybackRequest()
val shouldForwardSubtitles = playerSettingsUiState.externalPlayerForwardSubtitles &&
!playerSettingsUiState.preferredSubtitleLanguage.equals(SubtitleLanguageOption.NONE, ignoreCase = true)
val shouldSendSkipSegments = playerSettingsUiState.externalPlayerSendSkipSegments
if (shouldForwardSubtitles) {
StreamsRepository.setOverlayVisible(true, getString(Res.string.streams_loading_subtitles))
} else if (shouldSendSkipSegments) {
StreamsRepository.setOverlayVisible(true, getString(Res.string.streams_loading_skip_segments))
}
val enrichedRequest = prepareExternalPlayerLaunch(
request = baseRequest,
type = launch.contentType ?: launch.parentMetaType,
videoId = launch.videoId ?: launch.parentMetaId,
forwardSubtitles = playerSettingsUiState.externalPlayerForwardSubtitles,
sendSkipSegments = shouldSendSkipSegments,
preferredLanguage = playerSettingsUiState.preferredSubtitleLanguage,
secondaryLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage,
onOverlayMessage = { _ -> },
)
StreamsRepository.setOverlayVisible(false)
return when (
val intentResult = ExternalPlayerPlatform.buildIntent(
request = enrichedRequest,
playerId = playerSettingsUiState.externalPlayerId,
)
) {
is ExternalPlayerIntentResult.Success -> {
val launched = launchExternalPlayer(intentResult)
if (!launched) {
NuvioToastController.show(externalPlayerFailedText)
}
launched
}
ExternalPlayerIntentResult.NotConfigured -> {
NuvioToastController.show(externalPlayerNotConfiguredText)
false
}
ExternalPlayerIntentResult.Failed -> {
NuvioToastController.show(externalPlayerFailedText)
false
}
}
}
fun openDownloadedItem(item: DownloadItem) {
val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return
val resumeEntry = item.videoId
.takeIf { it.isNotBlank() }
?.let(WatchProgressRepository::progressForVideo)
?.takeIf { it.isResumable }
val playerLaunch = PlayerLaunch(
profileId = activePlaybackProfileId,
title = item.title,
sourceUrl = sourceUrl,
sourceHeaders = emptyMap(),
sourceResponseHeaders = emptyMap(),
externalSubtitles = emptyList(),
streamType = null,
logo = item.logo,
poster = item.poster,
background = item.background,
seasonNumber = item.seasonNumber,
episodeNumber = item.episodeNumber,
episodeTitle = item.episodeTitle,
episodeThumbnail = item.episodeThumbnail,
streamTitle = item.streamTitle,
streamSubtitle = item.streamSubtitle,
providerName = item.providerName,
providerAddonId = item.providerAddonId,
contentType = item.contentType,
videoId = item.videoId,
parentMetaId = item.parentMetaId,
parentMetaType = item.parentMetaType,
initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L,
initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f },
)
if (playerSettingsUiState.externalPlayerEnabled) {
coroutineScope.launch { openExternalPlayback(playerLaunch) }
return
}
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title))
}
fun openExternalStreamUrl(url: String): Boolean {
val opened = runCatching {
uriHandler.openUri(url)
}.isSuccess
if (!opened) {
NuvioToastController.show(failedOpenBrowserText)
}
return opened
}
suspend fun launchCloudLibraryFile(
item: CloudLibraryItem,
file: CloudLibraryFile,
resumePositionMs: Long? = null,
resumeProgressFraction: Float? = null,
startFromBeginning: Boolean = false,
): Boolean {
return when (
val resolved = CloudLibraryRepository.resolvePlayback(
item = item,
file = file,
)
) {
is CloudLibraryPlaybackResult.Success -> {
val playbackTitle = resolved.filename
?.takeIf { it.isNotBlank() }
?: file.name.ifBlank { item.name }
val playerLaunch = PlayerLaunch(
profileId = activePlaybackProfileId,
title = playbackTitle,
sourceUrl = resolved.url,
streamTitle = playbackTitle,
streamSubtitle = item.name.takeIf { it != playbackTitle },
providerName = item.providerName,
providerAddonId = "cloud:${item.providerId}",
poster = item.providerPosterUrl(),
contentType = CloudLibraryContentType,
videoId = item.playbackVideoId(file),
parentMetaId = item.stableKey,
parentMetaType = CloudLibraryContentType,
initialPositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L),
initialProgressFraction = if (startFromBeginning) null else resumeProgressFraction,
)
if (playerSettingsUiState.externalPlayerEnabled) {
openExternalPlayback(playerLaunch)
true
} else {
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title))
true
}
}
else -> false
}
}
fun launchPlaybackWithDownloadPreference(
type: String,
videoId: String,
parentMetaId: String,
parentMetaType: String,
title: String,
logo: String?,
poster: String?,
background: String?,
seasonNumber: Int?,
episodeNumber: Int?,
episodeTitle: String?,
episodeThumbnail: String?,
pauseDescription: String?,
resumePositionMs: Long?,
resumeProgressFraction: Float?,
manualSelection: Boolean,
startFromBeginning: Boolean,
) {
val targetResumePositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L)
val targetResumeProgressFraction = if (startFromBeginning) null else resumeProgressFraction
if (!manualSelection) {
val downloadedItem = DownloadsRepository.findPlayableDownload(
parentMetaId = parentMetaId,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
videoId = videoId,
)
val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri)
if (!localSourceUrl.isNullOrBlank()) {
val playerLaunch = PlayerLaunch(
profileId = activePlaybackProfileId,
title = title,
sourceUrl = localSourceUrl,
sourceHeaders = emptyMap(),
sourceResponseHeaders = emptyMap(),
externalSubtitles = emptyList(),
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
streamTitle = downloadedItem.streamTitle.ifBlank { title },
streamSubtitle = downloadedItem.streamSubtitle,
pauseDescription = pauseDescription,
providerName = downloadedItem.providerName.ifBlank { downloadedProviderLabel },
providerAddonId = downloadedItem.providerAddonId,
contentType = type,
videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
initialPositionMs = targetResumePositionMs,
initialProgressFraction = targetResumeProgressFraction,
)
if (playerSettingsUiState.externalPlayerEnabled) {
coroutineScope.launch { openExternalPlayback(playerLaunch) }
return
}
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title))
return
}
}
val streamLaunchId = StreamLaunchStore.put(
StreamLaunch(
profileId = activePlaybackProfileId,
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
title = title,
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
pauseDescription = pauseDescription,
resumePositionMs = if (startFromBeginning) 0L else resumePositionMs,
resumeProgressFraction = targetResumeProgressFraction,
manualSelection = manualSelection,
startFromBeginning = startFromBeginning,
),
)
navController.navigate(
StreamRoute(launchId = streamLaunchId, title = title),
)
}
val onPlay: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit =
{ type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs ->
launchPlaybackWithDownloadPreference(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
title = title,
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
pauseDescription = pauseDescription,
resumePositionMs = resumePositionMs,
resumeProgressFraction = null,
manualSelection = false,
startFromBeginning = false,
)
}
val onPlayManually: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit =
{ type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs ->
launchPlaybackWithDownloadPreference(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
title = title,
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
pauseDescription = pauseDescription,
resumePositionMs = resumePositionMs,
resumeProgressFraction = null,
manualSelection = true,
startFromBeginning = false,
)
}
val onCatalogClick: (HomeCatalogSection) -> Unit = { section ->
val launchId = CatalogLaunchStore.put(
CatalogLaunch(
title = section.title,
subtitle = section.subtitle,
target = section.target,
),
)
navController.navigate(
CatalogRoute(
launchId = launchId,
title = section.title,
subtitle = section.subtitle,
),
)
}
val librarySectionSubtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) {
stringResource(Res.string.compose_catalog_subtitle_trakt_library)
} else {
stringResource(Res.string.compose_catalog_subtitle_library)
}
val onLibrarySectionViewAllClick: (LibrarySection, LibrarySortOption) -> Unit = { section, sortOption ->
val launchId = CatalogLaunchStore.put(
CatalogLaunch(
title = section.displayTitle,
subtitle = librarySectionSubtitle,
target = CatalogTarget.Library(
contentType = section.items.firstOrNull()?.type ?: "movie",
sectionType = section.type,
sortOption = sortOption,
),
),
)
navController.navigate(
CatalogRoute(
launchId = launchId,
title = section.displayTitle,
subtitle = librarySectionSubtitle,
),
)
}
val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning ->
resumePromptItem = null
if (item.isCloudLibraryContinueWatchingItem()) {
coroutineScope.launch {
when (
val lookup = CloudLibraryRepository.findPlaybackTargetForProgressResult(
contentId = item.parentMetaId,
videoId = item.videoId,
)
) {
is CloudLibraryPlaybackTargetLookupResult.Found -> {
val launched = launchCloudLibraryFile(
item = lookup.target.item,
file = lookup.target.file,
resumePositionMs = item.resumePositionMs,
resumeProgressFraction = item.resumeProgressFraction,
startFromBeginning = startFromBeginning,
)
if (!launched) {
NuvioToastController.show(cloudLibraryPlayFailedText)
}
}
CloudLibraryPlaybackTargetLookupResult.Disabled -> {
NuvioToastController.show(cloudLibraryPlayDisabledText)
}
is CloudLibraryPlaybackTargetLookupResult.NotConnected -> {
val providerName = lookup.providerName?.takeIf { it.isNotBlank() }
NuvioToastController.show(
providerName?.let { name ->
getString(Res.string.cloud_library_play_provider_not_connected, name)
}
?: cloudLibraryPlayNotConnectedText,
)
}
CloudLibraryPlaybackTargetLookupResult.NotFound -> {
NuvioToastController.show(cloudLibraryPlayFailedText)
}
}
}
} else {
launchPlaybackWithDownloadPreference(
type = item.parentMetaType,
videoId = item.videoId,
parentMetaId = item.parentMetaId,
parentMetaType = item.parentMetaType,
title = item.title,
logo = item.logo,
poster = item.poster,
background = item.background,
seasonNumber = item.seasonNumber,
episodeNumber = item.episodeNumber,
episodeTitle = item.episodeTitle,
episodeThumbnail = item.episodeThumbnail,
pauseDescription = item.pauseDescription,
resumePositionMs = item.resumePositionMs,
resumeProgressFraction = item.resumeProgressFraction,
manualSelection = manualSelection,
startFromBeginning = startFromBeginning,
)
}
}
val onContinueWatchingClick: (ContinueWatchingItem) -> Unit = { item ->
openContinueWatching(item, false, false)
}
val onContinueWatchingStartFromBeginning: (ContinueWatchingItem) -> Unit = { item ->
openContinueWatching(item, false, true)
}
val onContinueWatchingPlayManually: (ContinueWatchingItem) -> Unit = { item ->
openContinueWatching(item, true, false)
}
val onContinueWatchingRemove: (ContinueWatchingItem) -> Unit = { item ->
if (item.isNextUp) {
ContinueWatchingPreferencesRepository.addDismissedNextUpKey(
nextUpDismissKey(
item.parentMetaId,
item.nextUpSeedSeasonNumber,
item.nextUpSeedEpisodeNumber,
),
)
} else {
WatchProgressRepository.removeProgress(contentId = item.parentMetaId)
}
}
val onContinueWatchingLongPress: (ContinueWatchingItem) -> Unit = { item ->
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
val zoomAnchor = PosterZoomAnchorHolder.consume()
selectedContinueWatchingZoomAnchor = zoomAnchor
selectedContinueWatchingForActions = item
}
AppUpdaterHost(
controller = appUpdaterController,
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.nuvio.colors.background),
) {
Box(
modifier = Modifier
.fillMaxSize()
.then(
if (selectedPosterActionTarget != null || selectedContinueWatchingZoomAnchor != null) {
Modifier.hazeSource(state = posterOverlayHazeState)
} else {
Modifier
},
)
.background(MaterialTheme.nuvio.colors.background),
) {
SharedTransitionLayout {
CompositionLocalProvider(
LocalUseNativeNavigation provides useNativeNavigation,
LocalNativeNavigationBarHidden provides (currentRoute?.hidesNavigationBar == true),
) {
NavDisplay(
backStack = navBackStack,
modifier = Modifier.fillMaxSize(),
onBack = { navController.popBackStack() },
sharedTransitionScope = this@SharedTransitionLayout,
entryProvider = entryProvider {
entry<TabsRoute> {
PlatformBackHandler(
enabled = true,
onBack = {
if (selectedTab != AppScreenTab.Home) {
activateTab(AppScreenTab.Home)
} else {
showExitConfirmation = !showExitConfirmation
}
},
)
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val isTabletLayout = useTabletFloatingTabBar || maxWidth >= 768.dp
val useNativeBottomTabs = if (useNativeNavigation) {
useNativeTabBar
} else {
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
}
val tabsRouteActive = currentRoute is TabsRoute
val navBarScrollState = rememberNuvioNavBarScrollState()
val navBarHazeState = rememberHazeState()
val navBarStyleSetting by remember { ThemeSettingsRepository.navBarStyle }.collectAsStateWithLifecycle()
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
profileSwitchLoading = true
NativeTabBridge.publishTabBarVisible(false)
activateTab(AppScreenTab.Home)
ProfileRepository.selectProfile(profile.profileIndex)
com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex)
}
Scaffold(
modifier = Modifier
.fillMaxSize()
.alpha(if (initialHomeReady) 1f else 0f),
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0),
bottomBar = {
if (!isTabletLayout && !useNativeBottomTabs && navBarStyleSetting == NavBarStyle.CLASSIC) {
NuvioClassicNavigationBar {
NavItem(
selected = selectedTab == AppScreenTab.Home,
onClick = { handleRootTabClick(AppScreenTab.Home) },
icon = Icons.Filled.Home,
contentDescription = stringResource(Res.string.compose_nav_home),
)
NavItem(
selected = selectedTab == AppScreenTab.Search,
onClick = { handleRootTabClick(AppScreenTab.Search) },
icon = Res.drawable.sidebar_search,
contentDescription = stringResource(Res.string.compose_nav_search),
)
NavItem(
selected = selectedTab == AppScreenTab.Library,
onClick = { handleRootTabClick(AppScreenTab.Library) },
icon = Res.drawable.sidebar_library,
contentDescription = stringResource(Res.string.compose_nav_library),
)
NavItem(
selected = selectedTab == AppScreenTab.Settings,
onClick = { handleRootTabClick(AppScreenTab.Settings) },
) {
ProfileSwitcherTab(
selected = selectedTab == AppScreenTab.Settings,
onClick = { handleRootTabClick(AppScreenTab.Settings) },
onProfileSelected = onProfileSelected,
onAddProfileRequested = onSwitchProfile,
)
}
}
}
},
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize()) {
CompositionLocalProvider(
LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else if (!isTabletLayout && navBarStyleSetting != NavBarStyle.CLASSIC) 72.dp else 0.dp,
LocalNuvioNavBarScrollState provides navBarScrollState,
) {
AppTabHost(
modifier = Modifier
.fillMaxSize()
.then(if (navBarStyleSetting != NavBarStyle.CLASSIC) Modifier.hazeSource(state = navBarHazeState) else Modifier)
.then(if (navBarStyleSetting == NavBarStyle.ADAPTIVE) Modifier.nestedScroll(navBarScrollState.nestedScrollConnection) else Modifier)
.padding(innerPadding),
selectedTab = selectedTab,
searchFocusRequestCount = searchFocusRequestCount,
rootActionsEnabled = tabsRouteActive,
homeScrollToTopRequests = homeScrollToTopRequests,
searchScrollToTopRequests = searchScrollToTopRequests,
libraryScrollToTopRequests = libraryScrollToTopRequests,
settingsRootActionRequests = settingsRootActionRequests,
animateHomeCollectionGifs = tabsRouteActive,
onCatalogClick = onCatalogClick,
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id, title = meta.name))
},
onPosterLongClick = { meta ->
openPosterActions(PosterActionTarget(preview = meta))
},
onLibraryPosterClick = { item ->
navController.navigate(DetailRoute(type = item.type, id = item.id, title = item.name))
},
onLibraryPosterLongClick = { item, section ->
openPosterActions(
PosterActionTarget(
preview = item.toMetaPreview(),
libraryItem = item,
libraryListKey = section.type,
),
)
},
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
onCloudFilePlay = { item, file ->
coroutineScope.launch {
val resumeItem = WatchProgressRepository
.progressForVideo(
videoId = item.playbackVideoId(file),
parentMetaId = item.id,
)
?.takeIf { it.isResumable }
?.toContinueWatchingItem()
if (
!launchCloudLibraryFile(
item = item,
file = file,
resumePositionMs = resumeItem?.resumePositionMs,
resumeProgressFraction = resumeItem?.resumeProgressFraction,
)
) {
NuvioToastController.show(cloudLibraryPlayFailedText)
}
}
},
onConnectCloudClick = {
if (useNativeNavigation && !isTabletLayout) {
activateTab(AppScreenTab.Settings)
navController.navigate(
SettingsPageRoute(
pageName = "Debrid",
title = debridSettingsTitle,
)
)
} else {
requestedSettingsPageName = "Debrid"
activateTab(AppScreenTab.Settings)
}
},
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
onSwitchProfile = onSwitchProfile,
onSettingsPageClick = if (useNativeNavigation && !isTabletLayout) {
{ pageName, title ->
navController.navigate(SettingsPageRoute(pageName, title))
}
} else {
null
},
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute(homescreenSettingsTitle)) },
onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute(metaScreenSettingsTitle)) },
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute(continueWatchingSettingsTitle)) },
onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute(downloadsSettingsTitle)) },
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute(addonsSettingsTitle)) },
onPluginsSettingsClick = {
if (AppFeaturePolicy.pluginsEnabled) {
navController.navigate(PluginsSettingsRoute(pluginsSettingsTitle))
}
},
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute(accountSettingsTitle)) },
onSupportersContributorsSettingsClick = {
if (AppFeaturePolicy.supportersContributorsPageEnabled) {
navController.navigate(SupportersContributorsSettingsRoute(supportersSettingsTitle))
}
},
onLicensesAttributionsSettingsClick = {
navController.navigate(LicensesAttributionsSettingsRoute(licensesSettingsTitle))
},
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
{
appUpdaterController.checkForUpdates(
force = true,
showNoUpdateFeedback = true,
)
}
} else {
null
},
onTestUpdateBannerClick = if (
AppFeaturePolicy.inAppUpdaterEnabled && AppUpdaterPlatform.isDebugBuild
) {
appUpdaterController::showDebugTestUpdate
} else {
null
},
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute(collectionsTitle)) },
onFolderClick = { collectionId, folderId ->
val folderTitle = CollectionRepository.collections.value
.firstOrNull { it.id == collectionId }
?.folders
?.firstOrNull { it.id == folderId }
?.title
.orEmpty()
navController.navigate(
FolderDetailRoute(
collectionId = collectionId,
folderId = folderId,
title = folderTitle.ifBlank { collectionsTitle },
)
)
},
requestedSettingsPageName = requestedSettingsPageName,
onRequestedSettingsPageConsumed = {
requestedSettingsPageName = null
},
onInitialHomeContentRendered = { initialHomeReady = true },
)
}
if (isTabletLayout && !useNativeBottomTabs) {
TabletFloatingTopBar(
selectedTab = selectedTab,
onTabSelected = ::handleRootTabClick,
onProfileSelected = onProfileSelected,
onAddProfileRequested = onSwitchProfile,
)
}
// Floating pill navigation bar overlay
if (!isTabletLayout && !useNativeBottomTabs && navBarStyleSetting != NavBarStyle.CLASSIC) {
// Force expand/collapse for non-adaptive modes
when (navBarStyleSetting) {
NavBarStyle.EXPANDED -> navBarScrollState.expand()
NavBarStyle.COMPACT -> navBarScrollState.collapse()
else -> {} // ADAPTIVE — scroll controls it
}
NuvioNavigationBar(
modifier = Modifier.align(Alignment.BottomCenter),
scrollState = navBarScrollState,
hazeState = navBarHazeState,
) {
NavItem(
selected = selectedTab == AppScreenTab.Home,
onClick = { handleRootTabClick(AppScreenTab.Home) },
icon = Icons.Filled.Home,
contentDescription = stringResource(Res.string.compose_nav_home),
label = stringResource(Res.string.compose_nav_home),
)
NavItem(
selected = selectedTab == AppScreenTab.Search,
onClick = { handleRootTabClick(AppScreenTab.Search) },
icon = Res.drawable.sidebar_search,
contentDescription = stringResource(Res.string.compose_nav_search),
label = stringResource(Res.string.compose_nav_search),
)
NavItem(
selected = selectedTab == AppScreenTab.Library,
onClick = { handleRootTabClick(AppScreenTab.Library) },
icon = Res.drawable.sidebar_library,
contentDescription = stringResource(Res.string.compose_nav_library),
label = stringResource(Res.string.compose_nav_library),
)
NavItem(
selected = selectedTab == AppScreenTab.Settings,
onClick = { handleRootTabClick(AppScreenTab.Settings) },
label = stringResource(Res.string.compose_nav_profile),
) {
ProfileSwitcherTab(
selected = selectedTab == AppScreenTab.Settings,
onClick = { handleRootTabClick(AppScreenTab.Settings) },
onProfileSelected = onProfileSelected,
onAddProfileRequested = onSwitchProfile,
)
}
}
}
}
}
}
}
entry<DetailRoute> { route ->
val onBack = rememberGuardedPopBackStack(navController, route)
val animatedVisibilityScope = LocalNavAnimatedContentScope.current
val directorRole = stringResource(Res.string.person_role_director)
val writerRole = stringResource(Res.string.person_role_writer)
val creatorRole = stringResource(Res.string.person_role_creator)
MetaDetailsScreen(
type = route.type,
id = route.id,
onBack = onBack,
onPlay = onPlay,
onPlayManually = onPlayManually,
onOpenMeta = { preview ->
coroutineScope.launch {
val resolvedId = if (preview.id.startsWith("tmdb:")) {
val tmdbId = preview.id.removePrefix("tmdb:").toIntOrNull()
tmdbId?.let {
TmdbService.tmdbToImdb(
tmdbId = it,
mediaType = preview.type,
)
} ?: preview.id
} else {
preview.id
}
navController.navigate(
DetailRoute(
type = preview.type,
id = resolvedId,
title = preview.name,
),
)
}
},
onCastClick = { person, avatarTransitionKey ->
val tmdbId = person.tmdbId
if (tmdbId != null && tmdbId > 0) {
navController.navigate(
PersonDetailRoute(
personId = tmdbId,
personName = person.name,
personPhoto = person.photo,
castAvatarTransitionKey = avatarTransitionKey,
preferCrew = person.role?.let {
it.equals("Director", ignoreCase = true) ||
it.equals(directorRole, ignoreCase = true) ||
it.equals("Writer", ignoreCase = true) ||
it.equals(writerRole, ignoreCase = true) ||
it.equals("Creator", ignoreCase = true)
|| it.equals(creatorRole, ignoreCase = true)
} ?: false,
),
)
}
},
onCompanyClick = { company, entityKind ->
val tmdbId = company.tmdbId
if (tmdbId != null && tmdbId > 0) {
navController.navigate(
EntityBrowseRoute(
entityKind = entityKind,
entityId = tmdbId,
entityName = company.name,
sourceType = route.type,
),
)
}
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = animatedVisibilityScope,
modifier = Modifier.fillMaxSize(),
)
}
entry<PersonDetailRoute> { route ->
val onBack = rememberGuardedPopBackStack(navController, route)
val animatedVisibilityScope = LocalNavAnimatedContentScope.current
PersonDetailScreen(
personId = route.personId,
personName = route.personName,
initialProfilePhoto = route.personPhoto,
avatarTransitionKey = route.castAvatarTransitionKey,
preferCrew = route.preferCrew,
onBack = onBack,
onOpenMeta = { preview ->
coroutineScope.launch {
val resolvedId = if (preview.id.startsWith("tmdb:")) {
val tmdbId = preview.id.removePrefix("tmdb:").toIntOrNull()
tmdbId?.let {
TmdbService.tmdbToImdb(
tmdbId = it,
mediaType = preview.type,
)
} ?: preview.id
} else {
preview.id
}
navController.navigate(
DetailRoute(
type = preview.type,
id = resolvedId,
title = preview.name,
),
)
}
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = animatedVisibilityScope,
modifier = Modifier.fillMaxSize(),
)
}
entry<EntityBrowseRoute> { route ->
val onBack = rememberGuardedPopBackStack(navController, route)
TmdbEntityBrowseScreen(
entityKind = TmdbEntityKind.fromRouteValue(route.entityKind),
entityId = route.entityId,
entityName = route.entityName,
sourceType = route.sourceType,
onBack = onBack,
onOpenMeta = { preview ->
coroutineScope.launch {
val resolvedId = if (preview.id.startsWith("tmdb:")) {
val tmdbId = preview.id.removePrefix("tmdb:").toIntOrNull()
tmdbId?.let {
TmdbService.tmdbToImdb(
tmdbId = it,
mediaType = preview.type,
)
} ?: preview.id
} else {
preview.id
}
navController.navigate(
DetailRoute(
type = preview.type,
id = resolvedId,
title = preview.name,
),
)
}
},
modifier = Modifier.fillMaxSize(),
)
}
entry<StreamRoute> { route ->
val onBack = rememberGuardedPopBackStack(navController, route)
val launch = remember(route.launchId) {
StreamLaunchStore.get(route.launchId)
}
if (launch == null) {
LaunchedEffect(route.launchId) {
onBack()
}
return@entry
}
val pauseDescription = launch.pauseDescription
val streamRouteScope = rememberCoroutineScope()
var resolvingDebridStream by rememberSaveable(route.launchId) { mutableStateOf(false) }
var pendingP2pStreamOpen by remember { mutableStateOf<PendingP2pStreamOpen?>(null) }
val shouldResolveEpisodeVideoId =
launch.parentMetaId != null &&
launch.seasonNumber != null &&
launch.episodeNumber != null
var effectiveVideoId by rememberSaveable(
launch.videoId,
launch.parentMetaId,
launch.seasonNumber,
launch.episodeNumber,
) {
mutableStateOf(launch.videoId)
}
var hasResolvedVideoId by rememberSaveable(
launch.videoId,
launch.parentMetaId,
launch.seasonNumber,
launch.episodeNumber,
) {
mutableStateOf(!shouldResolveEpisodeVideoId)
}
LaunchedEffect(
launch.videoId,
launch.parentMetaId,
launch.parentMetaType,
launch.type,
launch.seasonNumber,
launch.episodeNumber,
) {
effectiveVideoId = launch.videoId
if (!shouldResolveEpisodeVideoId) {
hasResolvedVideoId = true
return@LaunchedEffect
}
hasResolvedVideoId = false
val metaType = launch.parentMetaType ?: launch.type
val metaId = launch.parentMetaId ?: return@LaunchedEffect
val resolvedVideoId = runCatching {
MetaDetailsRepository.fetch(metaType, metaId)
}.getOrNull()
?.videos
?.firstOrNull { video ->
video.season == launch.seasonNumber &&
video.episode == launch.episodeNumber
}
?.id
?.takeIf { it.isNotBlank() }
effectiveVideoId = resolvedVideoId ?: launch.videoId
hasResolvedVideoId = true
}
val playerSettings by remember {
PlayerSettingsRepository.ensureLoaded()
PlayerSettingsRepository.uiState
}.collectAsStateWithLifecycle()
fun p2pSentinelUrl(infoHash: String, fileIdx: Int?): String =
"torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}"
fun openP2pStream(
stream: StreamItem,
resolvedResumePositionMs: Long?,
resolvedResumeProgressFraction: Float?,
replaceStreamRoute: Boolean,
) {
val infoHash = stream.p2pInfoHash ?: return
val sentinelUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
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 = "",
streamName = stream.streamLabel,
addonName = stream.addonName,
addonId = stream.addonId,
requestHeaders = emptyMap(),
responseHeaders = emptyMap(),
filename = stream.behaviorHints.filename,
videoSize = stream.behaviorHints.videoSize,
infoHash = infoHash,
fileIdx = stream.p2pFileIdx,
sources = stream.sources,
bingeGroup = stream.behaviorHints.bingeGroup,
)
}
val playerLaunch = PlayerLaunch(
profileId = launch.profileId,
title = launch.title,
sourceUrl = sentinelUrl,
sourceHeaders = emptyMap(),
sourceResponseHeaders = emptyMap(),
streamType = stream.streamType,
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,
torrentInfoHash = infoHash,
torrentFileIdx = stream.p2pFileIdx,
torrentFilename = stream.behaviorHints.filename,
torrentTrackers = stream.p2pTrackers,
initialPositionMs = resolvedResumePositionMs ?: 0L,
initialProgressFraction = resolvedResumeProgressFraction,
)
val launchId = PlayerLaunchStore.put(playerLaunch)
StreamsRepository.cancelLoading()
navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title)) {
if (replaceStreamRoute) {
popUpTo<StreamRoute> { inclusive = true }
}
}
}
fun requestOrOpenP2pStream(
stream: StreamItem,
resolvedResumePositionMs: Long?,
resolvedResumeProgressFraction: Float?,
forceExternal: Boolean,
forceInternal: Boolean,
isAutoPlay: Boolean,
) {
if (stream.p2pInfoHash == null) {
if (isAutoPlay) StreamsRepository.skipAutoPlayStream(stream)
return
}
if (!P2pSettingsRepository.isVisible) {
if (isAutoPlay) StreamsRepository.skipAutoPlayStream(stream)
return
}
if (!p2pSettingsUiState.p2pEnabled) {
pendingP2pStreamOpen = PendingP2pStreamOpen(
stream = stream,
resumePositionMs = resolvedResumePositionMs,
resumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = forceExternal,
forceInternal = forceInternal,
isAutoPlay = isAutoPlay,
)
return
}
openP2pStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
replaceStreamRoute = isAutoPlay,
)
}
// Reuse Last Link: auto-play from cache if enabled (only on first entry)
var reuseHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) }
var reuseNavigated by remember { mutableStateOf(false) }
LaunchedEffect(effectiveVideoId, hasResolvedVideoId, playerSettings.streamReuseLastLinkEnabled, launch.manualSelection) {
if (!hasResolvedVideoId) return@LaunchedEffect
if (reuseHandled) return@LaunchedEffect
reuseHandled = true
if (launch.manualSelection) return@LaunchedEffect
if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect
val cacheKey = StreamLinkCacheRepository.contentKey(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
)
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
if (cached != null) {
if (cached.url.isBlank() && !cached.infoHash.isNullOrBlank()) {
val cachedStream = StreamItem(
name = cached.streamName,
url = null,
infoHash = cached.infoHash,
fileIdx = cached.fileIdx,
sources = cached.sources,
addonName = cached.addonName,
addonId = cached.addonId,
behaviorHints = StreamBehaviorHints(
filename = cached.filename,
videoSize = cached.videoSize,
bingeGroup = cached.bingeGroup,
),
)
requestOrOpenP2pStream(
stream = cachedStream,
resolvedResumePositionMs = launch.resumePositionMs,
resolvedResumeProgressFraction = launch.resumeProgressFraction,
forceExternal = false,
forceInternal = true,
isAutoPlay = true,
)
reuseNavigated = true
return@LaunchedEffect
}
val playerLaunch = PlayerLaunch(
profileId = launch.profileId,
title = launch.title,
sourceUrl = cached.url,
sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders),
externalSubtitles = emptyList(),
streamType = cached.streamType,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
seasonNumber = launch.seasonNumber,
episodeNumber = launch.episodeNumber,
episodeTitle = launch.episodeTitle,
episodeThumbnail = launch.episodeThumbnail,
streamTitle = cached.streamName,
streamSubtitle = null,
bingeGroup = cached.bingeGroup,
pauseDescription = pauseDescription,
providerName = cached.addonName,
providerAddonId = cached.addonId,
contentType = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
parentMetaType = launch.parentMetaType ?: launch.type,
initialPositionMs = launch.resumePositionMs ?: 0L,
initialProgressFraction = launch.resumeProgressFraction,
contentLanguage = cached.contentLanguage,
)
if (playerSettings.externalPlayerEnabled) {
openExternalPlayback(playerLaunch)
StreamsRepository.setOverlayVisible(false)
reuseNavigated = true
return@LaunchedEffect
}
StreamsRepository.clear()
reuseNavigated = true
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title)) {
popUpTo<StreamRoute> { inclusive = true }
}
}
}
val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
val expectedStreamsRequestToken = StreamsRepository.requestToken(
type = launch.type,
videoId = effectiveVideoId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
manualSelection = launch.manualSelection,
)
var autoPlayHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) }
LaunchedEffect(
streamsUiState.autoPlayStream,
streamsUiState.requestToken,
expectedStreamsRequestToken,
reuseHandled,
launch.manualSelection,
) {
if (!reuseHandled) return@LaunchedEffect
if (launch.manualSelection) return@LaunchedEffect
if (reuseNavigated) return@LaunchedEffect
if (autoPlayHandled) return@LaunchedEffect
if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect
val selectedStream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
val stream = if (DirectDebridPlaybackResolver.shouldResolveToPlayableStream(selectedStream)) {
when (
val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream(
stream = selectedStream,
season = launch.seasonNumber,
episode = launch.episodeNumber,
)
) {
is DirectDebridPlayableResult.Success -> resolved.stream
else -> {
val hasNextCandidate = StreamsRepository.skipAutoPlayStream(selectedStream)
if (!hasNextCandidate) {
resolved.toastMessage()?.let { NuvioToastController.show(it) }
}
if (!hasNextCandidate && resolved == DirectDebridPlayableResult.Stale) {
StreamsRepository.reload(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
manualSelection = launch.manualSelection,
)
}
return@LaunchedEffect
}
}
} else {
selectedStream
}
val sourceUrl = stream.playableDirectUrl
if (sourceUrl == null && stream.needsLocalDebridResolve && stream.p2pInfoHash != null) {
autoPlayHandled = true
requestOrOpenP2pStream(
stream = stream,
resolvedResumePositionMs = launch.resumePositionMs,
resolvedResumeProgressFraction = launch.resumeProgressFraction,
forceExternal = false,
forceInternal = true,
isAutoPlay = true,
)
StreamsRepository.consumeAutoPlay()
return@LaunchedEffect
}
if (sourceUrl == null) {
StreamsRepository.skipAutoPlayStream(selectedStream)
return@LaunchedEffect
}
autoPlayHandled = true
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,
streamType = stream.streamType,
)
}
val playerLaunch = PlayerLaunch(
profileId = launch.profileId,
title = launch.title,
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
externalSubtitles = stream.externalSubtitles,
streamType = stream.streamType,
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 = launch.resumePositionMs ?: 0L,
initialProgressFraction = launch.resumeProgressFraction,
)
if (playerSettings.externalPlayerEnabled) {
openExternalPlayback(playerLaunch)
StreamsRepository.consumeAutoPlay()
StreamsRepository.cancelLoading()
return@LaunchedEffect
}
StreamsRepository.consumeAutoPlay()
StreamsRepository.cancelLoading()
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title)) {
popUpTo<StreamRoute> { inclusive = true }
}
}
if (!hasResolvedVideoId) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
NuvioLoadingIndicator(color = MaterialTheme.nuvio.colors.accent)
}
return@entry
}
fun openSelectedStream(
stream: StreamItem,
resolvedResumePositionMs: Long?,
resolvedResumeProgressFraction: Float?,
forceExternal: Boolean,
forceInternal: Boolean,
) {
if (DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) {
if (resolvingDebridStream) return
streamRouteScope.launch {
resolvingDebridStream = true
val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream(
stream = stream,
season = launch.seasonNumber,
episode = launch.episodeNumber,
)
resolvingDebridStream = false
when (resolved) {
is DirectDebridPlayableResult.Success -> openSelectedStream(
stream = resolved.stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = forceExternal,
forceInternal = forceInternal,
)
else -> {
resolved.toastMessage()?.let { NuvioToastController.show(it) }
if (resolved == DirectDebridPlayableResult.Stale) {
StreamsRepository.reload(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
manualSelection = launch.manualSelection,
)
}
}
}
}
return
}
if (stream.needsLocalDebridResolve && stream.p2pInfoHash != null) {
requestOrOpenP2pStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = forceExternal,
forceInternal = forceInternal,
isAutoPlay = false,
)
return
}
if (stream.shouldOpenExternally) {
val opened = stream.externalOpenUrl?.let(::openExternalStreamUrl) == true
if (opened) {
StreamsRepository.cancelLoading()
}
return
}
val sourceUrl = stream.playableDirectUrl ?: 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,
streamType = stream.streamType,
)
}
val playerLaunch = PlayerLaunch(
profileId = launch.profileId,
title = launch.title,
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
externalSubtitles = stream.externalSubtitles,
streamType = stream.streamType,
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)) {
streamRouteScope.launch {
openExternalPlayback(playerLaunch)
StreamsRepository.cancelLoading()
}
return
}
val launchId = PlayerLaunchStore.put(playerLaunch)
StreamsRepository.cancelLoading()
navController.navigate(
PlayerRoute(launchId = launchId, title = playerLaunch.title)
)
}
// Hide overlay when reuse navigated to external player (prevents reload from showing it again)
LaunchedEffect(reuseNavigated) {
if (reuseNavigated) {
StreamsRepository.setOverlayVisible(false)
}
}
Box(modifier = Modifier.fillMaxSize()) {
StreamsScreen(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
parentMetaType = launch.parentMetaType ?: launch.type,
title = launch.title,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
seasonNumber = launch.seasonNumber,
episodeNumber = launch.episodeNumber,
episodeTitle = launch.episodeTitle,
episodeThumbnail = launch.episodeThumbnail,
resumePositionMs = launch.resumePositionMs,
resumeProgressFraction = launch.resumeProgressFraction,
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,
)
},
onBack = onBack,
modifier = Modifier.fillMaxSize(),
)
pendingP2pStreamOpen?.let { pending ->
P2pConsentDialog(
onEnableP2p = {
P2pSettingsRepository.setP2pEnabled(true)
pendingP2pStreamOpen = null
openP2pStream(
stream = pending.stream,
resolvedResumePositionMs = pending.resumePositionMs,
resolvedResumeProgressFraction = pending.resumeProgressFraction,
replaceStreamRoute = pending.isAutoPlay,
)
},
onDismiss = {
if (pending.isAutoPlay) {
StreamsRepository.skipAutoPlayStream(pending.stream)
StreamsRepository.consumeAutoPlay()
}
pendingP2pStreamOpen = null
},
)
}
if (resolvingDebridStream) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.nuvio.colors.overlayScrim.copy(alpha = MaterialTheme.nuvio.opacity.overlayHeavy)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.nuvio.spacing.cardPadding),
) {
NuvioLoadingIndicator(color = MaterialTheme.nuvio.colors.playerControlsForeground)
Text(
text = stringResource(Res.string.streams_finding_source),
color = MaterialTheme.nuvio.colors.playerControlsForeground.copy(alpha = MaterialTheme.nuvio.opacity.overlayHeavy),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
entry<PlayerRoute>(
metadata = if (isIos) {
NavDisplay.transitionSpec {
fadeIn(animationSpec = tween(220)) togetherWith
fadeOut(animationSpec = tween(220))
} + NavDisplay.popTransitionSpec {
fadeIn(animationSpec = tween(220)) togetherWith
fadeOut(animationSpec = tween(220))
}
} else {
emptyMap()
},
) { route ->
val onBack = rememberGuardedPopBackStack(navController, route)
val launch = remember(route.launchId) { PlayerLaunchStore.get(route.launchId) }
if (launch == null) {
LaunchedEffect(route.launchId) {
onBack()
}
Box(modifier = Modifier.fillMaxSize())
return@entry
}
LaunchedEffect(launch.videoId) {
launch.videoId?.let { ResumePromptRepository.markPlayerEntered(it) }
}
PlayerScreen(
profileId = launch.profileId,
title = launch.title,
sourceUrl = launch.sourceUrl,
sourceAudioUrl = launch.sourceAudioUrl,
sourceHeaders = launch.sourceHeaders,
sourceResponseHeaders = launch.sourceResponseHeaders,
externalSubtitles = launch.externalSubtitles,
streamType = launch.streamType,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
seasonNumber = launch.seasonNumber,
episodeNumber = launch.episodeNumber,
episodeTitle = launch.episodeTitle,
episodeThumbnail = launch.episodeThumbnail,
streamTitle = launch.streamTitle,
streamSubtitle = launch.streamSubtitle,
initialBingeGroup = launch.bingeGroup,
pauseDescription = launch.pauseDescription,
providerName = launch.providerName,
providerAddonId = launch.providerAddonId,
contentType = launch.contentType,
videoId = launch.videoId,
parentMetaId = launch.parentMetaId,
parentMetaType = launch.parentMetaType,
torrentInfoHash = launch.torrentInfoHash,
torrentFileIdx = launch.torrentFileIdx,
torrentFilename = launch.torrentFilename,
torrentTrackers = launch.torrentTrackers,
initialPositionMs = launch.initialPositionMs,
initialProgressFraction = launch.initialProgressFraction,
contentLanguage = launch.contentLanguage,
onBack = onBack,
onOpenInExternalPlayer = { request ->
val playerLaunch = PlayerLaunch(
profileId = launch.profileId,
title = launch.title,
sourceUrl = request.sourceUrl,
sourceHeaders = request.sourceHeaders,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
seasonNumber = launch.seasonNumber,
episodeNumber = launch.episodeNumber,
episodeTitle = launch.episodeTitle,
episodeThumbnail = launch.episodeThumbnail,
streamTitle = request.streamTitle ?: launch.streamTitle,
streamSubtitle = launch.streamSubtitle,
bingeGroup = launch.bingeGroup,
pauseDescription = launch.pauseDescription,
providerName = launch.providerName,
providerAddonId = launch.providerAddonId,
contentType = launch.contentType,
videoId = launch.videoId,
parentMetaId = launch.parentMetaId,
parentMetaType = launch.parentMetaType,
initialPositionMs = request.resumePositionMs,
)
lastExternalPlayerLaunch = playerLaunch
val intentResult = ExternalPlayerPlatform.buildIntent(
request = request,
playerId = playerSettingsUiState.externalPlayerId,
)
when (intentResult) {
is ExternalPlayerIntentResult.Success -> {
val launched = launchExternalPlayer(intentResult)
if (!launched) {
NuvioToastController.show(externalPlayerFailedText)
}
}
ExternalPlayerIntentResult.NotConfigured -> {
NuvioToastController.show(externalPlayerNotConfiguredText)
}
ExternalPlayerIntentResult.Failed -> {
NuvioToastController.show(externalPlayerFailedText)
}
}
},
onOpenExternalUrl = { url ->
openExternalStreamUrl(url)
},
modifier = Modifier.fillMaxSize(),
)
}
entry<CatalogRoute> { route ->
val onBack = rememberGuardedPopBackStack(navController, route)
val launch = remember(route.launchId) { CatalogLaunchStore.get(route.launchId) }
if (launch == null) {
LaunchedEffect(route.launchId) {
onBack()
}
return@entry
}
val target = launch.target
CatalogScreen(
title = launch.title,
subtitle = launch.subtitle,
target = target,
onBack = onBack,
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id, title = meta.name))
},
onPosterLongClick = { meta ->
openPosterActions(
if (target is CatalogTarget.Library) {
PosterActionTarget(
preview = meta,
libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L),
libraryListKey = target.sectionType,
)
} else {
PosterActionTarget(preview = meta)
},
)
},
modifier = Modifier.fillMaxSize(),
)
}
entry<HomescreenSettingsRoute> { route ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
route = route,
)
HomescreenSettingsScreen(
onBack = onBack,
)
}
entry<MetaScreenSettingsRoute> { route ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
route = route,
)
MetaScreenSettingsScreen(
onBack = onBack,
)
}
entry<ContinueWatchingSettingsRoute> { route ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
route = route,
)
ContinueWatchingSettingsScreen(
onBack = onBack,
)
}
entry<SettingsPageRoute> { route ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
route = route,
)
SettingsScreen(
modifier = Modifier.fillMaxSize(),
initialPageName = route.pageName,
rootActionsEnabled = false,
onNavigatePage = { pageName, title ->
navController.navigate(SettingsPageRoute(pageName, title))
},
onExternalBack = onBack,
showInternalHeader = !useNativeNavigation,
onDownloadsClick = {
navController.navigate(DownloadsSettingsRoute(downloadsSettingsTitle))
},
onCollectionsClick = {
navController.navigate(CollectionsRoute(collectionsTitle))
},
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
{
appUpdaterController.checkForUpdates(
force = true,
showNoUpdateFeedback = true,
)
}
} else {
null
},
onTestUpdateBannerClick = if (
AppFeaturePolicy.inAppUpdaterEnabled && AppUpdaterPlatform.isDebugBuild
) {
appUpdaterController::showDebugTestUpdate
} else {
null
},
)
}
entry<DownloadsSettingsRoute> { route ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
route = route,
)
DownloadsScreen(
onBack = onBack,
onOpenDownload = ::openDownloadedItem,
onNavigateToShow = if (useNativeNavigation) {
{ showId, title ->
navController.navigate(DownloadShowRoute(showId, title))
}
} else {
null
},
)
}
entry<DownloadShowRoute> { route ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
route = route,
)
DownloadsScreen(
onBack = onBack,
onOpenDownload = ::openDownloadedItem,
initialShowId = route.showId,
onBackFromShow = onBack,
)
}
entry<AddonsSettingsRoute> { route ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
route = route,
)
AddonsSettingsScreen(
onBack = onBack,
)
}
if (AppFeaturePolicy.pluginsEnabled) {
entry<PluginsSettingsRoute> { route ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
route = route,
)
PluginsSettingsScreen(
onBack = onBack,
)
}
}
entry<AccountSettingsRoute> { route ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
route = route,
)
AccountSettingsScreen(
onBack = onBack,
)
}
entry<SupportersContributorsSettingsRoute> { route ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
route = route,
)
if (AppFeaturePolicy.supportersContributorsPageEnabled) {
SupportersContributorsSettingsScreen(
onBack = onBack,
)
} else {
LaunchedEffect(Unit) {
onBack()
}
}
}
entry<LicensesAttributionsSettingsRoute> { route ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
route = route,
)
LicensesAttributionsSettingsScreen(
onBack = onBack,
)
}
entry<CollectionsRoute> { route ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
route = route,
)
CollectionManagementScreen(
onBack = onBack,
onNavigateToEditor = { collectionId ->
val editorTitle = collectionId
?.let { id ->
CollectionRepository.collections.value.firstOrNull { it.id == id }?.title
}
.orEmpty()
navController.navigate(
CollectionEditorRoute(
collectionId = collectionId,
title = editorTitle.ifBlank { newCollectionTitle },
)
)
},
)
}
entry<CollectionEditorRoute> { route ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
route = route,
)
CollectionEditorScreen(
collectionId = route.collectionId,
onBack = onBack,
initialPage = if (useNativeNavigation) CollectionEditorPage.Root else null,
onNavigateToPage = if (useNativeNavigation) {
{ page, title ->
navController.navigate(
CollectionEditorPageRoute(
collectionId = route.collectionId,
pageName = page.name,
title = title,
)
)
}
} else {
null
},
)
}
entry<CollectionEditorPageRoute> { route ->
val page = remember(route.pageName) {
runCatching { CollectionEditorPage.valueOf(route.pageName) }.getOrNull()
}
val onBack = rememberGuardedPopBackStack(
navController = navController,
route = route,
)
if (page == null || page == CollectionEditorPage.Root) {
LaunchedEffect(route) { onBack() }
return@entry
}
CollectionEditorScreen(
collectionId = route.collectionId,
initialPage = page,
initializeRepository = false,
onBack = onBack,
onNavigateToPage = { nextPage, title ->
navController.navigate(
CollectionEditorPageRoute(
collectionId = route.collectionId,
pageName = nextPage.name,
title = title,
)
)
},
)
}
entry<FolderDetailRoute> { route ->
val onBack = rememberGuardedPopBackStack(navController, route)
LaunchedEffect(route.collectionId, route.folderId) {
FolderDetailRepository.initialize(route.collectionId, route.folderId)
}
FolderDetailScreen(
onBack = onBack,
onCatalogClick = onCatalogClick,
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id, title = meta.name))
},
)
}
},
)
}
}
}
selectedPosterActionTarget?.let { posterActionTarget ->
key(posterActionTarget) {
val preview = posterActionTarget.preview
val isSaved = LibraryRepository.isSaved(preview.id, preview.type)
val isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedUiState.watchedKeys,
item = preview,
)
// Trakt items long-pressed outside the library open the list picker
// instead of removing, so only true removals disintegrate.
val removesFromLibrary = isSaved &&
(posterActionTarget.libraryItem != null || !isTraktLibrarySource)
NuvioPosterZoomActionOverlay(
imageUrl = selectedPosterAnchor?.imageUrl ?: preview.poster,
title = preview.name,
subtitle = preview.releaseInfo
?.takeIf { it.isNotBlank() }
?.let { formatReleaseDateForDisplay(it) }
?: preview.type.replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
},
isWatched = isWatched,
anchor = selectedPosterAnchor,
actions = listOf(
PosterZoomOverlayAction(
icon = if (isSaved) Icons.Default.DeleteOutline else Icons.Default.Add,
label = if (isSaved) {
stringResource(Res.string.hero_remove_from_library)
} else {
stringResource(Res.string.hero_add_to_library)
},
isDestructive = removesFromLibrary,
onSelected = {
val libraryItem = posterActionTarget.libraryItem
?: preview.toLibraryItem(savedAtEpochMs = 0L)
if (posterActionTarget.libraryItem != null) {
if (isTraktLibrarySource) {
coroutineScope.launch {
runCatching {
val listKey = posterActionTarget.libraryListKey
if (listKey.isNullOrBlank()) {
val currentMembership = LibraryRepository.getMembershipSnapshot(libraryItem)
LibraryRepository.applyMembershipChanges(
item = libraryItem,
desiredMembership = currentMembership.mapValues { false },
)
} else {
LibraryRepository.removeFromList(libraryItem, listKey)
}
}.onFailure { error ->
NuvioToastController.show(
error.message ?: getString(Res.string.trakt_lists_update_failed),
)
}
}
} else {
LibraryRepository.remove(libraryItem.id)
}
} else {
if (!isTraktLibrarySource) {
LibraryRepository.toggleSaved(libraryItem)
} else {
pickerItem = libraryItem
pickerTitle = preview.name
pickerTabs = LibraryRepository.libraryListTabs()
pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true
pickerError = null
showLibraryListPicker = true
coroutineScope.launch {
runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.libraryListTabs()
pickerTabs = tabs
pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true)
}
}.onFailure { error ->
pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
}
pickerPending = false
}
}
}
},
),
PosterZoomOverlayAction(
icon = if (isWatched) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline,
label = if (isWatched) {
stringResource(Res.string.hero_mark_unwatched)
} else {
stringResource(Res.string.hero_mark_watched)
},
onSelected = {
coroutineScope.launch {
WatchingActions.togglePosterWatched(preview)
}
},
),
),
hazeState = posterOverlayHazeState,
onDismissed = {
selectedPosterActionTarget = null
selectedPosterAnchor = null
},
)
}
}
selectedContinueWatchingForActions?.let { item ->
selectedContinueWatchingZoomAnchor?.let { anchor ->
key(item.videoId, anchor) {
val showManualPlayOption = StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState)
val showDetailsOption = !item.isCloudLibraryContinueWatchingItem()
NuvioPosterZoomActionOverlay(
imageUrl = cloudLibraryDisplayArtworkUrl(anchor.imageUrl ?: item.poster ?: item.imageUrl),
title = item.title,
subtitle = localizedContinueWatchingSubtitle(item),
anchor = anchor,
actions = buildList {
if (showDetailsOption) {
add(
PosterZoomOverlayAction(
icon = Icons.Default.Info,
label = stringResource(Res.string.cw_action_go_to_details),
onSelected = {
navController.navigate(
DetailRoute(
type = item.parentMetaType,
id = item.parentMetaId,
title = item.title,
),
)
},
),
)
}
if (showManualPlayOption) {
add(
PosterZoomOverlayAction(
icon = Icons.Default.PlayArrow,
label = stringResource(Res.string.play_manually),
onSelected = { onContinueWatchingPlayManually(item) },
),
)
}
if (!item.isNextUp) {
add(
PosterZoomOverlayAction(
icon = Icons.Default.Replay,
label = stringResource(Res.string.cw_action_start_from_beginning),
onSelected = { onContinueWatchingStartFromBeginning(item) },
),
)
}
add(
PosterZoomOverlayAction(
icon = Icons.Default.DeleteOutline,
label = stringResource(Res.string.cw_action_remove),
isDestructive = true,
onSelected = { onContinueWatchingRemove(item) },
),
)
},
hazeState = posterOverlayHazeState,
onDismissed = {
selectedContinueWatchingForActions = null
selectedContinueWatchingZoomAnchor = null
},
)
}
}
}
NuvioContinueWatchingActionSheet(
item = selectedContinueWatchingForActions.takeIf { selectedContinueWatchingZoomAnchor == null },
showManualPlayOption = StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState),
showDetailsOption = selectedContinueWatchingForActions?.isCloudLibraryContinueWatchingItem() != true,
onDismiss = { selectedContinueWatchingForActions = null },
onOpenDetails = {
selectedContinueWatchingForActions?.let { item ->
navController.navigate(
DetailRoute(
type = item.parentMetaType,
id = item.parentMetaId,
title = item.title,
),
)
}
},
onStartFromBeginning = selectedContinueWatchingForActions
?.takeIf { !it.isNextUp }
?.let { item -> { onContinueWatchingStartFromBeginning(item) } },
onPlayManually = selectedContinueWatchingForActions
?.let { item -> { onContinueWatchingPlayManually(item) } },
onRemove = {
selectedContinueWatchingForActions?.let(onContinueWatchingRemove)
},
)
TraktListPickerDialog(
visible = showLibraryListPicker,
title = pickerTitle,
tabs = pickerTabs,
membership = pickerMembership,
isPending = pickerPending,
errorMessage = pickerError,
onToggle = { listKey ->
pickerMembership = pickerMembership.toMutableMap().apply {
this[listKey] = !(this[listKey] == true)
}
},
onDismiss = {
if (!pickerPending) {
showLibraryListPicker = false
pickerItem = null
pickerError = null
}
},
onSave = {
val item = pickerItem ?: return@TraktListPickerDialog
coroutineScope.launch {
pickerPending = true
pickerError = null
runCatching {
LibraryRepository.applyMembershipChanges(
item = item,
desiredMembership = pickerMembership,
)
}.onSuccess {
showLibraryListPicker = false
pickerItem = null
pickerError = null
}.onFailure { error ->
pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed)
}
pickerPending = false
}
},
)
NuvioStatusModal(
title = stringResource(Res.string.app_exit_title),
message = stringResource(Res.string.app_exit_message),
isVisible = showExitConfirmation,
confirmText = stringResource(Res.string.action_yes),
dismissText = stringResource(Res.string.action_no),
onConfirm = {
showExitConfirmation = false
platformExitApp()
},
onDismiss = {
showExitConfirmation = false
},
)
androidx.compose.animation.AnimatedVisibility(
visibleState = launchOverlayState,
enter = fadeIn(),
exit = fadeOut(androidx.compose.animation.core.tween(400)),
) {
AppLaunchOverlay(
profileColor = launchOverlayProfileColor,
modifier = Modifier.fillMaxSize(),
)
}
// Auto-dismiss profile switch overlay
if (profileSwitchLoading) {
LaunchedEffect(Unit) {
// Brief loading screen while home refreshes for the new profile
kotlinx.coroutines.delay(1200)
profileSwitchLoading = false
}
}
NuvioFloatingPrompt(
visible = resumePromptItem != null,
imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl,
title = resumePromptItem?.title.orEmpty(),
subtitle = resumePromptItem?.let { localizedContinueWatchingSubtitle(it) }.orEmpty(),
progressFraction = resumePromptItem?.progressFraction ?: 0f,
actionLabel = stringResource(Res.string.resume_prompt_action),
onAction = {
val item = resumePromptItem ?: return@NuvioFloatingPrompt
resumePromptItem = null
openContinueWatching(item, false, false)
},
onDismiss = { resumePromptItem = null },
modifier = Modifier
.align(Alignment.BottomCenter)
.zIndex(15f),
)
NuvioToastHost(
modifier = Modifier
.align(Alignment.TopCenter)
.zIndex(20f),
)
}
}
}
@Composable
private fun rememberGuardedPopBackStack(
navController: NuvioNavigator,
route: AppRoute,
beforePop: () -> Unit = {},
): () -> Unit {
var popHandled by remember(route) { mutableStateOf(false) }
return remember(navController, route, popHandled, beforePop) {
{
if (!popHandled && navController.currentRoute == route) {
popHandled = true
beforePop()
navController.popBackStack(expectedRoute = route)
}
}
}
}
@Composable
private fun AppTabHost(
selectedTab: AppScreenTab,
modifier: Modifier = Modifier,
searchFocusRequestCount: Int = 0,
rootActionsEnabled: Boolean = true,
homeScrollToTopRequests: Flow<Unit>,
searchScrollToTopRequests: Flow<Unit>,
libraryScrollToTopRequests: Flow<Unit>,
settingsRootActionRequests: Flow<Unit>,
animateHomeCollectionGifs: Boolean = true,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
onLibraryPosterClick: ((LibraryItem) -> Unit)? = null,
onLibraryPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null,
onLibrarySectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)? = null,
onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null,
onConnectCloudClick: (() -> Unit)? = null,
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null,
onSwitchProfile: (() -> Unit)? = null,
onSettingsPageClick: ((pageName: String, title: String) -> Unit)? = null,
onHomescreenSettingsClick: () -> Unit = {},
onMetaScreenSettingsClick: () -> Unit = {},
onContinueWatchingSettingsClick: () -> Unit = {},
onDownloadsSettingsClick: () -> Unit = {},
onAddonsSettingsClick: () -> Unit = {},
onPluginsSettingsClick: () -> Unit = {},
onAccountSettingsClick: () -> Unit = {},
onSupportersContributorsSettingsClick: () -> Unit = {},
onLicensesAttributionsSettingsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onTestUpdateBannerClick: (() -> Unit)? = null,
onCollectionsSettingsClick: () -> Unit = {},
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
requestedSettingsPageName: String? = null,
onRequestedSettingsPageConsumed: () -> Unit = {},
onInitialHomeContentRendered: () -> Unit = {},
) {
val tabStateHolder = rememberSaveableStateHolder()
Box(modifier = modifier.fillMaxSize()) {
tabStateHolder.SaveableStateProvider(selectedTab.name) {
when (selectedTab) {
AppScreenTab.Home -> {
HomeScreen(
modifier = Modifier.fillMaxSize(),
animateCollectionGifs = animateHomeCollectionGifs,
scrollToTopRequests = homeScrollToTopRequests,
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
onFolderClick = onFolderClick,
onFirstCatalogRendered = onInitialHomeContentRendered,
)
}
AppScreenTab.Search -> {
SearchScreen(
modifier = Modifier.fillMaxSize(),
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
searchFocusRequestCount = searchFocusRequestCount,
scrollToTopRequests = searchScrollToTopRequests,
)
}
AppScreenTab.Library -> {
LibraryScreen(
modifier = Modifier.fillMaxSize(),
scrollToTopRequests = libraryScrollToTopRequests,
onPosterClick = onLibraryPosterClick,
onPosterLongClick = onLibraryPosterLongClick,
onSectionViewAllClick = onLibrarySectionViewAllClick,
onCloudFilePlay = onCloudFilePlay,
onConnectCloudClick = onConnectCloudClick,
)
}
AppScreenTab.Settings -> {
SettingsScreen(
modifier = Modifier.fillMaxSize(),
rootActionRequests = settingsRootActionRequests,
requestedPageName = requestedSettingsPageName,
onRequestedPageConsumed = onRequestedSettingsPageConsumed,
rootActionsEnabled = rootActionsEnabled,
onNavigatePage = onSettingsPageClick,
onSwitchProfile = onSwitchProfile,
onHomescreenClick = onHomescreenSettingsClick,
onMetaScreenClick = onMetaScreenSettingsClick,
onContinueWatchingClick = onContinueWatchingSettingsClick,
onDownloadsClick = onDownloadsSettingsClick,
onAddonsClick = onAddonsSettingsClick,
onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick,
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
onLicensesAttributionsClick = onLicensesAttributionsSettingsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onTestUpdateBannerClick = onTestUpdateBannerClick,
onCollectionsClick = onCollectionsSettingsClick,
)
}
}
}
}
}
@Composable
private fun TabletFloatingTopBar(
selectedTab: AppScreenTab,
onTabSelected: (AppScreenTab) -> Unit,
onProfileSelected: (NuvioProfile) -> Unit,
onAddProfileRequested: () -> Unit,
modifier: Modifier = Modifier,
) {
val tokens = MaterialTheme.nuvio
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(
modifier = modifier
.fillMaxWidth()
.padding(top = statusBarPadding + NuvioTokens.Space.s10, bottom = tokens.spacing.controlGap),
contentAlignment = Alignment.TopCenter,
) {
Surface(
color = tokens.colors.surface.copy(alpha = tokens.opacity.visible - tokens.opacity.subtle),
shape = tokens.shapes.chip,
tonalElevation = tokens.elevation.playerControls,
shadowElevation = tokens.elevation.overlay,
) {
Row(
modifier = Modifier.padding(horizontal = NuvioTokens.Space.s10, vertical = tokens.spacing.controlGap),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
verticalAlignment = Alignment.CenterVertically,
) {
TabletTopPillItem(
label = stringResource(Res.string.compose_nav_home),
selected = selectedTab == AppScreenTab.Home,
onClick = { onTabSelected(AppScreenTab.Home) },
icon = {
Icon(
imageVector = Icons.Filled.Home,
contentDescription = stringResource(Res.string.compose_nav_home),
modifier = Modifier.size(NuvioTokens.Space.s18),
tint = if (selectedTab == AppScreenTab.Home) {
tokens.colors.textPrimary
} else {
tokens.colors.textMuted
},
)
},
)
TabletTopPillItem(
label = stringResource(Res.string.compose_nav_search),
selected = selectedTab == AppScreenTab.Search,
onClick = { onTabSelected(AppScreenTab.Search) },
icon = {
Icon(
painter = painterResource(Res.drawable.sidebar_search),
contentDescription = stringResource(Res.string.compose_nav_search),
modifier = Modifier.size(NuvioTokens.Space.s18),
tint = if (selectedTab == AppScreenTab.Search) {
tokens.colors.textPrimary
} else {
tokens.colors.textMuted
},
)
},
)
TabletTopPillItem(
label = stringResource(Res.string.compose_nav_library),
selected = selectedTab == AppScreenTab.Library,
onClick = { onTabSelected(AppScreenTab.Library) },
icon = {
Icon(
painter = painterResource(Res.drawable.sidebar_library),
contentDescription = stringResource(Res.string.compose_nav_library),
modifier = Modifier.size(NuvioTokens.Space.s18),
tint = if (selectedTab == AppScreenTab.Library) {
tokens.colors.textPrimary
} else {
tokens.colors.textMuted
},
)
},
)
Surface(
color = if (selectedTab == AppScreenTab.Settings) {
tokens.colors.overlaySelected
} else {
tokens.colors.surface
},
shape = tokens.shapes.chip,
) {
Row(
modifier = Modifier.padding(horizontal = tokens.spacing.listGap, vertical = tokens.spacing.controlGap),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
verticalAlignment = Alignment.CenterVertically,
) {
ProfileSwitcherTab(
selected = selectedTab == AppScreenTab.Settings,
onClick = { onTabSelected(AppScreenTab.Settings) },
onProfileSelected = onProfileSelected,
onAddProfileRequested = onAddProfileRequested,
)
Text(
text = stringResource(Res.string.compose_nav_profile),
modifier = Modifier.clickable { onTabSelected(AppScreenTab.Settings) },
style = MaterialTheme.typography.labelLarge,
color = if (selectedTab == AppScreenTab.Settings) {
tokens.colors.textPrimary
} else {
tokens.colors.textMuted
},
)
}
}
}
}
}
}
private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean =
parentMetaType.equals(CloudLibraryContentType, ignoreCase = true)
@Composable
private fun TabletTopPillItem(
label: String,
selected: Boolean,
onClick: () -> Unit,
icon: @Composable () -> Unit,
) {
val tokens = MaterialTheme.nuvio
Surface(
color = if (selected) tokens.colors.overlaySelected else tokens.colors.surface,
shape = tokens.shapes.chip,
tonalElevation = if (selected) tokens.elevation.raised else tokens.elevation.flat,
modifier = Modifier.clickable(onClick = onClick),
) {
Row(
modifier = Modifier.padding(horizontal = tokens.components.chipHorizontalPadding, vertical = NuvioTokens.Space.s10),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
verticalAlignment = Alignment.CenterVertically,
) {
icon()
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = if (selected) {
tokens.colors.textPrimary
} else {
tokens.colors.textMuted
},
)
}
}
}
@Composable
private fun AppLaunchOverlay(
profileColor: Color,
modifier: Modifier = Modifier,
) {
val tokens = MaterialTheme.nuvio
Box(
modifier = modifier
.zIndex(NuvioTokens.Z.dialog),
contentAlignment = Alignment.Center,
) {
ProfileMeshBackground(
profileColor = profileColor,
modifier = Modifier.fillMaxSize(),
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(Res.drawable.app_logo_wordmark),
contentDescription = stringResource(Res.string.app_brand_name),
modifier = Modifier
.fillMaxWidth(0.48f)
.height(44.dp),
contentScale = ContentScale.Fit,
)
Spacer(modifier = Modifier.height(tokens.spacing.sectionGap))
NuvioLoadingIndicator(color = tokens.colors.accent)
}
}
}