mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +00:00
2248 lines
106 KiB
Kotlin
2248 lines
106 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.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.Home
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
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.mutableStateOf
|
|
import androidx.compose.runtime.saveable.rememberSaveable
|
|
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.draw.alpha
|
|
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.LocalHapticFeedback
|
|
import androidx.compose.ui.tooling.preview.Preview
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.zIndex
|
|
import androidx.lifecycle.Lifecycle
|
|
import androidx.lifecycle.LifecycleEventObserver
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import androidx.navigation.NavBackStackEntry
|
|
import androidx.navigation.NavController
|
|
import androidx.navigation.NavDestination.Companion.hasRoute
|
|
import androidx.navigation.NavHostController
|
|
import androidx.navigation.compose.NavHost
|
|
import androidx.navigation.compose.composable
|
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
|
import androidx.navigation.compose.rememberNavController
|
|
import androidx.navigation.toRoute
|
|
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.SyncManager
|
|
import com.nuvio.app.core.ui.NuvioNavigationBar
|
|
import com.nuvio.app.core.ui.NuvioContinueWatchingActionSheet
|
|
import com.nuvio.app.core.ui.NuvioPosterActionSheet
|
|
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.TraktListPickerDialog
|
|
import com.nuvio.app.core.ui.NuvioTheme
|
|
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
|
|
import com.nuvio.app.core.ui.NativeNavigationTab
|
|
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.features.auth.AuthScreen
|
|
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.INTERNAL_LIBRARY_MANIFEST_URL
|
|
import com.nuvio.app.features.downloads.DownloadsRepository
|
|
import com.nuvio.app.features.downloads.DownloadsScreen
|
|
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.LibrarySourceMode
|
|
import com.nuvio.app.features.library.LibraryScreen
|
|
import com.nuvio.app.features.library.toLibraryItem
|
|
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository
|
|
import com.nuvio.app.features.player.PlayerLaunch
|
|
import com.nuvio.app.features.player.PlayerLaunchStore
|
|
import com.nuvio.app.features.player.PlayerRoute
|
|
import com.nuvio.app.features.player.PlayerScreen
|
|
import com.nuvio.app.features.player.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.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.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.CollectionSyncService
|
|
import com.nuvio.app.features.home.HomeCatalogSettingsSyncService
|
|
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.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.TraktListTab
|
|
import com.nuvio.app.features.updater.AppUpdaterHost
|
|
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.WatchProgressRepository
|
|
import com.nuvio.app.features.watchprogress.nextUpDismissKey
|
|
import com.nuvio.app.features.watching.application.WatchingActions
|
|
import com.nuvio.app.features.watching.application.WatchingState
|
|
import kotlinx.coroutines.flow.collectLatest
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.serialization.Serializable
|
|
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
|
|
|
|
@Serializable
|
|
object TabsRoute
|
|
|
|
@Serializable
|
|
data class DetailRoute(val type: String, val id: String)
|
|
|
|
@Serializable
|
|
data class PersonDetailRoute(
|
|
val personId: Int,
|
|
val personName: String,
|
|
val personPhoto: String? = null,
|
|
val castAvatarTransitionKey: String? = null,
|
|
val preferCrew: Boolean = false,
|
|
)
|
|
|
|
@Serializable
|
|
data class EntityBrowseRoute(
|
|
val entityKind: String,
|
|
val entityId: Int,
|
|
val entityName: String,
|
|
val sourceType: String = "tv",
|
|
)
|
|
|
|
@Serializable
|
|
object HomescreenSettingsRoute
|
|
|
|
@Serializable
|
|
object MetaScreenSettingsRoute
|
|
|
|
@Serializable
|
|
object ContinueWatchingSettingsRoute
|
|
|
|
@Serializable
|
|
object DownloadsSettingsRoute
|
|
|
|
@Serializable
|
|
object AddonsSettingsRoute
|
|
|
|
@Serializable
|
|
object PluginsSettingsRoute
|
|
|
|
@Serializable
|
|
object AccountSettingsRoute
|
|
|
|
@Serializable
|
|
object SupportersContributorsSettingsRoute
|
|
|
|
@Serializable
|
|
object LicensesAttributionsSettingsRoute
|
|
|
|
@Serializable
|
|
object CollectionsRoute
|
|
|
|
@Serializable
|
|
data class CollectionEditorRoute(val collectionId: String? = null)
|
|
|
|
@Serializable
|
|
data class FolderDetailRoute(val collectionId: String, val folderId: String)
|
|
|
|
@Serializable
|
|
data class StreamRoute(
|
|
val launchId: Long,
|
|
)
|
|
|
|
@Serializable
|
|
data class CatalogRoute(
|
|
val title: String,
|
|
val subtitle: String,
|
|
val manifestUrl: String,
|
|
val type: String,
|
|
val catalogId: String,
|
|
val supportsPagination: Boolean = false,
|
|
val genre: String? = null,
|
|
)
|
|
|
|
enum class AppScreenTab {
|
|
Home,
|
|
Search,
|
|
Library,
|
|
Settings,
|
|
}
|
|
|
|
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 enum class AppGateScreen {
|
|
Loading,
|
|
Auth,
|
|
ProfileSelection,
|
|
ProfileEdit,
|
|
Main,
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
@Preview
|
|
fun App() {
|
|
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) {
|
|
LaunchedEffect(Unit) {
|
|
AuthRepository.initialize()
|
|
}
|
|
|
|
LaunchedEffect(Unit) {
|
|
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) }
|
|
|
|
fun enterProfileGate(profiles: List<NuvioProfile>, syncOnEnter: Boolean) {
|
|
if (profiles.isEmpty()) {
|
|
autoSkipProfileSelection = true
|
|
gateScreen = AppGateScreen.ProfileSelection.name
|
|
return
|
|
}
|
|
|
|
autoSkipProfileSelection = true
|
|
if (profiles.size == 1) {
|
|
val onlyProfile = profiles.first()
|
|
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 allowOfflineProfileAccess =
|
|
cachedProfiles.isNotEmpty() &&
|
|
authState !is AuthState.Authenticated &&
|
|
networkStatusUiState.condition != NetworkCondition.Online
|
|
|
|
when (authState) {
|
|
is AuthState.Loading -> {
|
|
if (allowOfflineProfileAccess) {
|
|
enterProfileGate(cachedProfiles, syncOnEnter = false)
|
|
} else {
|
|
gateScreen = AppGateScreen.Loading.name
|
|
}
|
|
}
|
|
is AuthState.Unauthenticated -> {
|
|
if (allowOfflineProfileAccess) {
|
|
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) {
|
|
if (
|
|
autoSkipProfileSelection &&
|
|
gateScreen == AppGateScreen.ProfileSelection.name &&
|
|
profileState.profiles.size == 1
|
|
) {
|
|
val onlyProfile = profileState.profiles.first()
|
|
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.colorScheme.background),
|
|
contentAlignment = Alignment.Center,
|
|
) {
|
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
|
}
|
|
}
|
|
AppGateScreen.Auth.name -> {
|
|
AuthScreen(modifier = Modifier.fillMaxSize())
|
|
}
|
|
AppGateScreen.ProfileSelection.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 -> {
|
|
ProfileEditScreen(
|
|
profile = editingProfile,
|
|
onBack = { gateScreen = AppGateScreen.ProfileSelection.name },
|
|
onSaved = { gateScreen = AppGateScreen.ProfileSelection.name },
|
|
modifier = Modifier.fillMaxSize(),
|
|
)
|
|
}
|
|
AppGateScreen.Main.name -> {
|
|
MainAppContent(
|
|
onSwitchProfile = {
|
|
autoSkipProfileSelection = false
|
|
gateScreen = AppGateScreen.ProfileSelection.name
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
|
|
@Composable
|
|
private fun MainAppContent(
|
|
onSwitchProfile: () -> Unit = {},
|
|
) {
|
|
val navController = rememberNavController()
|
|
val appUpdaterController = rememberAppUpdaterController()
|
|
remember {
|
|
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
|
}
|
|
remember {
|
|
CollectionSyncService.startObserving()
|
|
}
|
|
remember {
|
|
HomeCatalogSettingsSyncService.startObserving()
|
|
}
|
|
remember {
|
|
ProfileSettingsSync.startObserving()
|
|
}
|
|
val hapticFeedback = LocalHapticFeedback.current
|
|
val coroutineScope = rememberCoroutineScope()
|
|
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
|
|
var searchFocusRequestCount by remember { mutableStateOf(0) }
|
|
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
|
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
|
|
val liquidGlassNativeTabBarEnabled by remember {
|
|
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
|
|
}.collectAsStateWithLifecycle()
|
|
val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() }
|
|
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
|
|
var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(null) }
|
|
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(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 profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
|
val playerSettingsUiState by remember {
|
|
PlayerSettingsRepository.ensureLoaded()
|
|
PlayerSettingsRepository.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 isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
|
|
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
|
|
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
|
|
var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) }
|
|
var lastNetworkToastCondition by rememberSaveable { mutableStateOf(NetworkCondition.Unknown.name) }
|
|
|
|
val addonProbeTargets = remember(addonsUiState.addons) {
|
|
addonsUiState.addons
|
|
.mapNotNull { it.manifest?.transportUrl }
|
|
.distinct()
|
|
.sorted()
|
|
}
|
|
|
|
LaunchedEffect(nativeRequestedTab) {
|
|
if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) {
|
|
selectedTab = nativeRequestedTab.toAppScreenTab()
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(selectedTab) {
|
|
NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab())
|
|
if (selectedTab != AppScreenTab.Search) {
|
|
searchFocusRequestCount = 0
|
|
}
|
|
}
|
|
|
|
DisposableEffect(
|
|
navController,
|
|
liquidGlassNativeTabBarSupported,
|
|
liquidGlassNativeTabBarEnabled,
|
|
initialHomeReady,
|
|
) {
|
|
fun publishNativeTabVisibilityForCurrentRoute() {
|
|
val visible = liquidGlassNativeTabBarSupported &&
|
|
liquidGlassNativeTabBarEnabled &&
|
|
initialHomeReady &&
|
|
navController.currentDestination?.hasRoute<TabsRoute>() == true
|
|
NativeTabBridge.publishTabBarVisible(visible)
|
|
}
|
|
|
|
val destinationChangedListener = NavController.OnDestinationChangedListener { _, _, _ ->
|
|
publishNativeTabVisibilityForCurrentRoute()
|
|
}
|
|
|
|
publishNativeTabVisibilityForCurrentRoute()
|
|
navController.addOnDestinationChangedListener(destinationChangedListener)
|
|
onDispose {
|
|
navController.removeOnDestinationChangedListener(destinationChangedListener)
|
|
NativeTabBridge.publishTabBarVisible(false)
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(Unit) {
|
|
NetworkStatusRepository.ensureStarted()
|
|
EpisodeReleaseNotificationsRepository.refreshAsync()
|
|
kotlinx.coroutines.delay(5_000)
|
|
initialHomeReady = true
|
|
}
|
|
|
|
LaunchedEffect(addonProbeTargets) {
|
|
NetworkStatusRepository.updateAddonProbeTargets(addonProbeTargets)
|
|
}
|
|
|
|
LaunchedEffect(Unit) {
|
|
AppForegroundMonitor.events().collect {
|
|
NetworkStatusRepository.requestRefresh(force = true)
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(networkStatusUiState.condition) {
|
|
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(
|
|
initialHomeReady,
|
|
offlineLaunchRouteHandled,
|
|
networkStatusUiState.condition,
|
|
downloadsUiState.completedItems,
|
|
) {
|
|
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) {
|
|
selectedTab = AppScreenTab.Settings
|
|
navController.navigate(DownloadsSettingsRoute) {
|
|
launchSingleTop = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(authState, profileState.activeProfile?.profileIndex) {
|
|
val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect
|
|
if (authenticatedState.isAnonymous) return@LaunchedEffect
|
|
|
|
val activeProfileId = profileState.activeProfile?.profileIndex ?: return@LaunchedEffect
|
|
AppForegroundMonitor.events().collect {
|
|
SyncManager.requestForegroundPull(activeProfileId)
|
|
}
|
|
}
|
|
var profileSwitchLoading by remember { mutableStateOf(false) }
|
|
var resumePromptItem by remember { mutableStateOf<ContinueWatchingItem?>(null) }
|
|
val continueWatchingPreferencesUiState by remember {
|
|
ContinueWatchingPreferencesRepository.ensureLoaded()
|
|
ContinueWatchingPreferencesRepository.uiState
|
|
}.collectAsStateWithLifecycle()
|
|
|
|
LaunchedEffect(
|
|
initialHomeReady,
|
|
profileSwitchLoading,
|
|
profileState.activeProfile?.profileIndex,
|
|
continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
|
) {
|
|
if (!initialHomeReady || profileSwitchLoading) return@LaunchedEffect
|
|
if (resumePromptItem != null) return@LaunchedEffect
|
|
if (continueWatchingPreferencesUiState.showResumePromptOnLaunch) {
|
|
resumePromptItem = ResumePromptRepository.consumeResumePrompt()
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(navController) {
|
|
AppDeepLinkRepository.pendingDeepLink.collectLatest { deepLink ->
|
|
when (deepLink) {
|
|
is AppDeepLink.Meta -> {
|
|
selectedTab = AppScreenTab.Home
|
|
navController.navigate(DetailRoute(type = deepLink.type, id = deepLink.id)) {
|
|
launchSingleTop = true
|
|
}
|
|
AppDeepLinkRepository.markConsumed(deepLink)
|
|
}
|
|
|
|
AppDeepLink.Downloads -> {
|
|
selectedTab = AppScreenTab.Settings
|
|
navController.navigate(DownloadsSettingsRoute) {
|
|
launchSingleTop = true
|
|
}
|
|
AppDeepLinkRepository.markConsumed(deepLink)
|
|
}
|
|
|
|
null -> Unit
|
|
}
|
|
}
|
|
}
|
|
|
|
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 launchId = PlayerLaunchStore.put(
|
|
PlayerLaunch(
|
|
title = title,
|
|
sourceUrl = localSourceUrl,
|
|
sourceHeaders = emptyMap(),
|
|
sourceResponseHeaders = emptyMap(),
|
|
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,
|
|
),
|
|
)
|
|
navController.navigate(PlayerRoute(launchId = launchId))
|
|
return
|
|
}
|
|
}
|
|
|
|
val streamLaunchId = StreamLaunchStore.put(
|
|
StreamLaunch(
|
|
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),
|
|
)
|
|
}
|
|
|
|
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 ->
|
|
navController.navigate(
|
|
CatalogRoute(
|
|
title = section.title,
|
|
subtitle = section.subtitle,
|
|
manifestUrl = section.manifestUrl,
|
|
type = section.type,
|
|
catalogId = section.catalogId,
|
|
supportsPagination = section.supportsPagination,
|
|
),
|
|
)
|
|
}
|
|
|
|
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) -> Unit = { section ->
|
|
navController.navigate(
|
|
CatalogRoute(
|
|
title = section.displayTitle,
|
|
subtitle = librarySectionSubtitle,
|
|
manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL,
|
|
type = section.items.firstOrNull()?.type ?: "movie",
|
|
catalogId = section.type,
|
|
supportsPagination = false,
|
|
),
|
|
)
|
|
}
|
|
|
|
val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning ->
|
|
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 onContinueWatchingLongPress: (ContinueWatchingItem) -> Unit = { item ->
|
|
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
selectedContinueWatchingForActions = item
|
|
}
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(MaterialTheme.colorScheme.background),
|
|
) {
|
|
SharedTransitionLayout {
|
|
NavHost(
|
|
navController = navController,
|
|
startDestination = TabsRoute,
|
|
modifier = Modifier.fillMaxSize(),
|
|
) {
|
|
composable<TabsRoute> {
|
|
PlatformBackHandler(
|
|
enabled = true,
|
|
onBack = {
|
|
if (selectedTab != AppScreenTab.Home) {
|
|
selectedTab = AppScreenTab.Home
|
|
} else {
|
|
showExitConfirmation = !showExitConfirmation
|
|
}
|
|
},
|
|
)
|
|
|
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
|
val isTabletLayout = maxWidth >= 768.dp
|
|
val useNativeBottomTabs =
|
|
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
|
|
val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute<TabsRoute>() == true
|
|
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
|
|
profileSwitchLoading = true
|
|
selectedTab = 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) {
|
|
NuvioNavigationBar {
|
|
NavItem(
|
|
selected = selectedTab == AppScreenTab.Home,
|
|
onClick = { selectedTab = AppScreenTab.Home },
|
|
icon = Icons.Filled.Home,
|
|
contentDescription = stringResource(Res.string.compose_nav_home),
|
|
)
|
|
NavItem(
|
|
selected = selectedTab == AppScreenTab.Search,
|
|
onClick = {
|
|
if (selectedTab == AppScreenTab.Search) {
|
|
searchFocusRequestCount++
|
|
} else {
|
|
selectedTab = AppScreenTab.Search
|
|
}
|
|
},
|
|
icon = Res.drawable.sidebar_search,
|
|
contentDescription = stringResource(Res.string.compose_nav_search),
|
|
)
|
|
NavItem(
|
|
selected = selectedTab == AppScreenTab.Library,
|
|
onClick = { selectedTab = AppScreenTab.Library },
|
|
icon = Res.drawable.sidebar_library,
|
|
contentDescription = stringResource(Res.string.compose_nav_library),
|
|
)
|
|
NavItem(
|
|
selected = selectedTab == AppScreenTab.Settings,
|
|
onClick = { selectedTab = AppScreenTab.Settings },
|
|
) {
|
|
ProfileSwitcherTab(
|
|
selected = selectedTab == AppScreenTab.Settings,
|
|
onClick = { selectedTab = AppScreenTab.Settings },
|
|
onProfileSelected = onProfileSelected,
|
|
onAddProfileRequested = onSwitchProfile,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
) { innerPadding ->
|
|
Box(modifier = Modifier.fillMaxSize()) {
|
|
CompositionLocalProvider(
|
|
LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp,
|
|
) {
|
|
AppTabHost(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(innerPadding),
|
|
selectedTab = selectedTab,
|
|
searchFocusRequestCount = searchFocusRequestCount,
|
|
animateHomeCollectionGifs = tabsRouteActive,
|
|
onCatalogClick = onCatalogClick,
|
|
onPosterClick = { meta ->
|
|
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
|
},
|
|
onPosterLongClick = { meta ->
|
|
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
selectedPosterForActions = meta
|
|
},
|
|
onLibraryPosterClick = { item ->
|
|
navController.navigate(DetailRoute(type = item.type, id = item.id))
|
|
},
|
|
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
|
|
onContinueWatchingClick = onContinueWatchingClick,
|
|
onContinueWatchingLongPress = onContinueWatchingLongPress,
|
|
onSwitchProfile = onSwitchProfile,
|
|
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
|
|
onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
|
|
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
|
|
onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
|
|
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
|
|
onPluginsSettingsClick = {
|
|
if (AppFeaturePolicy.pluginsEnabled) {
|
|
navController.navigate(PluginsSettingsRoute)
|
|
}
|
|
},
|
|
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
|
|
onSupportersContributorsSettingsClick = {
|
|
navController.navigate(SupportersContributorsSettingsRoute)
|
|
},
|
|
onLicensesAttributionsSettingsClick = {
|
|
navController.navigate(LicensesAttributionsSettingsRoute)
|
|
},
|
|
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
|
|
{
|
|
appUpdaterController.checkForUpdates(
|
|
force = true,
|
|
showNoUpdateFeedback = true,
|
|
)
|
|
}
|
|
} else {
|
|
null
|
|
},
|
|
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
|
|
onFolderClick = { collectionId, folderId ->
|
|
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
|
|
},
|
|
onInitialHomeContentRendered = { initialHomeReady = true },
|
|
)
|
|
}
|
|
|
|
if (isTabletLayout && !useNativeBottomTabs) {
|
|
TabletFloatingTopBar(
|
|
selectedTab = selectedTab,
|
|
onTabSelected = { tab ->
|
|
if (tab == AppScreenTab.Search && selectedTab == AppScreenTab.Search) {
|
|
searchFocusRequestCount++
|
|
} else {
|
|
selectedTab = tab
|
|
}
|
|
},
|
|
onProfileSelected = onProfileSelected,
|
|
onAddProfileRequested = onSwitchProfile,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
composable<DetailRoute> { backStackEntry ->
|
|
val route = backStackEntry.toRoute<DetailRoute>()
|
|
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 = {
|
|
navController.popBackStack()
|
|
},
|
|
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,
|
|
),
|
|
)
|
|
}
|
|
},
|
|
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 = this,
|
|
modifier = Modifier.fillMaxSize(),
|
|
)
|
|
}
|
|
composable<PersonDetailRoute> { backStackEntry ->
|
|
val route = backStackEntry.toRoute<PersonDetailRoute>()
|
|
PersonDetailScreen(
|
|
personId = route.personId,
|
|
personName = route.personName,
|
|
initialProfilePhoto = route.personPhoto,
|
|
avatarTransitionKey = route.castAvatarTransitionKey,
|
|
preferCrew = route.preferCrew,
|
|
onBack = { navController.popBackStack() },
|
|
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,
|
|
),
|
|
)
|
|
}
|
|
},
|
|
sharedTransitionScope = this@SharedTransitionLayout,
|
|
animatedVisibilityScope = this,
|
|
modifier = Modifier.fillMaxSize(),
|
|
)
|
|
}
|
|
composable<EntityBrowseRoute> { backStackEntry ->
|
|
val route = backStackEntry.toRoute<EntityBrowseRoute>()
|
|
TmdbEntityBrowseScreen(
|
|
entityKind = TmdbEntityKind.fromRouteValue(route.entityKind),
|
|
entityId = route.entityId,
|
|
entityName = route.entityName,
|
|
sourceType = route.sourceType,
|
|
onBack = { navController.popBackStack() },
|
|
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,
|
|
),
|
|
)
|
|
}
|
|
},
|
|
modifier = Modifier.fillMaxSize(),
|
|
)
|
|
}
|
|
composable<StreamRoute> { backStackEntry ->
|
|
val route = backStackEntry.toRoute<StreamRoute>()
|
|
val launch = remember(route.launchId) {
|
|
StreamLaunchStore.get(route.launchId)
|
|
}
|
|
if (launch == null) {
|
|
LaunchedEffect(route.launchId) {
|
|
StreamsRepository.clear()
|
|
navController.popBackStack()
|
|
}
|
|
return@composable
|
|
}
|
|
val pauseDescription = launch.pauseDescription
|
|
val lifecycleOwner = backStackEntry
|
|
DisposableEffect(lifecycleOwner, route.launchId) {
|
|
val observer = LifecycleEventObserver { _, event ->
|
|
if (event == Lifecycle.Event.ON_DESTROY) {
|
|
StreamLaunchStore.remove(route.launchId)
|
|
}
|
|
}
|
|
lifecycleOwner.lifecycle.addObserver(observer)
|
|
onDispose {
|
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
|
}
|
|
}
|
|
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()
|
|
|
|
// 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(launch.type, effectiveVideoId)
|
|
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
|
|
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
|
|
if (cached != null) {
|
|
reuseNavigated = true
|
|
StreamsRepository.clear()
|
|
val launchId = PlayerLaunchStore.put(
|
|
PlayerLaunch(
|
|
title = launch.title,
|
|
sourceUrl = cached.url,
|
|
sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders),
|
|
sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders),
|
|
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,
|
|
)
|
|
)
|
|
navController.navigate(PlayerRoute(launchId = launchId)) {
|
|
popUpTo<StreamRoute> { inclusive = true }
|
|
}
|
|
}
|
|
}
|
|
|
|
val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
|
|
var autoPlayHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) }
|
|
LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled, launch.manualSelection) {
|
|
if (!reuseHandled) return@LaunchedEffect
|
|
if (launch.manualSelection) return@LaunchedEffect
|
|
if (reuseNavigated) return@LaunchedEffect
|
|
if (autoPlayHandled) return@LaunchedEffect
|
|
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
|
|
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
|
|
autoPlayHandled = true
|
|
if (playerSettings.streamReuseLastLinkEnabled) {
|
|
val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
|
|
StreamLinkCacheRepository.save(
|
|
contentKey = cacheKey,
|
|
url = sourceUrl,
|
|
streamName = stream.streamLabel,
|
|
addonName = stream.addonName,
|
|
addonId = stream.addonId,
|
|
requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
|
|
responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
|
|
filename = stream.behaviorHints.filename,
|
|
videoSize = stream.behaviorHints.videoSize,
|
|
bingeGroup = stream.behaviorHints.bingeGroup,
|
|
)
|
|
}
|
|
val launchId = PlayerLaunchStore.put(
|
|
PlayerLaunch(
|
|
title = launch.title,
|
|
sourceUrl = sourceUrl,
|
|
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
|
|
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
|
|
logo = launch.logo,
|
|
poster = launch.poster,
|
|
background = launch.background,
|
|
seasonNumber = launch.seasonNumber,
|
|
episodeNumber = launch.episodeNumber,
|
|
episodeTitle = launch.episodeTitle,
|
|
episodeThumbnail = launch.episodeThumbnail,
|
|
streamTitle = stream.streamLabel,
|
|
streamSubtitle = stream.streamSubtitle,
|
|
bingeGroup = stream.behaviorHints.bingeGroup,
|
|
pauseDescription = pauseDescription,
|
|
providerName = stream.addonName,
|
|
providerAddonId = stream.addonId,
|
|
contentType = launch.type,
|
|
videoId = effectiveVideoId,
|
|
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
|
|
parentMetaType = launch.parentMetaType ?: launch.type,
|
|
initialPositionMs = launch.resumePositionMs ?: 0L,
|
|
initialProgressFraction = launch.resumeProgressFraction,
|
|
)
|
|
)
|
|
StreamsRepository.consumeAutoPlay()
|
|
StreamsRepository.cancelLoading()
|
|
navController.navigate(PlayerRoute(launchId = launchId)) {
|
|
popUpTo<StreamRoute> { inclusive = true }
|
|
}
|
|
}
|
|
|
|
if (!hasResolvedVideoId) {
|
|
Box(
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentAlignment = Alignment.Center,
|
|
) {
|
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
|
}
|
|
return@composable
|
|
}
|
|
|
|
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 ->
|
|
val sourceUrl = stream.directPlaybackUrl
|
|
if (sourceUrl != null) {
|
|
// Persist for Reuse Last Link
|
|
if (playerSettings.streamReuseLastLinkEnabled) {
|
|
val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
|
|
StreamLinkCacheRepository.save(
|
|
contentKey = cacheKey,
|
|
url = sourceUrl,
|
|
streamName = stream.streamLabel,
|
|
addonName = stream.addonName,
|
|
addonId = stream.addonId,
|
|
requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
|
|
responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
|
|
filename = stream.behaviorHints.filename,
|
|
videoSize = stream.behaviorHints.videoSize,
|
|
bingeGroup = stream.behaviorHints.bingeGroup,
|
|
)
|
|
}
|
|
val launchId = PlayerLaunchStore.put(
|
|
PlayerLaunch(
|
|
title = launch.title,
|
|
sourceUrl = sourceUrl,
|
|
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
|
|
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
|
|
logo = launch.logo,
|
|
poster = launch.poster,
|
|
background = launch.background,
|
|
seasonNumber = launch.seasonNumber,
|
|
episodeNumber = launch.episodeNumber,
|
|
episodeTitle = launch.episodeTitle,
|
|
episodeThumbnail = launch.episodeThumbnail,
|
|
streamTitle = stream.streamLabel,
|
|
streamSubtitle = stream.streamSubtitle,
|
|
bingeGroup = stream.behaviorHints.bingeGroup,
|
|
pauseDescription = pauseDescription,
|
|
providerName = stream.addonName,
|
|
providerAddonId = stream.addonId,
|
|
contentType = launch.type,
|
|
videoId = effectiveVideoId,
|
|
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
|
|
parentMetaType = launch.parentMetaType ?: launch.type,
|
|
initialPositionMs = resolvedResumePositionMs ?: 0L,
|
|
initialProgressFraction = resolvedResumeProgressFraction,
|
|
)
|
|
)
|
|
StreamsRepository.cancelLoading()
|
|
navController.navigate(
|
|
PlayerRoute(launchId = launchId)
|
|
)
|
|
}
|
|
},
|
|
onBack = {
|
|
StreamsRepository.clear()
|
|
navController.popBackStack()
|
|
},
|
|
modifier = Modifier.fillMaxSize(),
|
|
)
|
|
}
|
|
composable<PlayerRoute>(
|
|
enterTransition = {
|
|
if (isIos) fadeIn(animationSpec = tween(220)) else null
|
|
},
|
|
exitTransition = {
|
|
if (isIos) fadeOut(animationSpec = tween(220)) else null
|
|
},
|
|
popEnterTransition = {
|
|
if (isIos) fadeIn(animationSpec = tween(220)) else null
|
|
},
|
|
popExitTransition = {
|
|
if (isIos) fadeOut(animationSpec = tween(220)) else null
|
|
},
|
|
) { backStackEntry ->
|
|
val route = backStackEntry.toRoute<PlayerRoute>()
|
|
val launch = remember(route.launchId) { PlayerLaunchStore.get(route.launchId) }
|
|
if (launch == null) {
|
|
LaunchedEffect(route.launchId) {
|
|
navController.popBackStack()
|
|
}
|
|
Box(modifier = Modifier.fillMaxSize())
|
|
return@composable
|
|
}
|
|
LaunchedEffect(launch.videoId) {
|
|
launch.videoId?.let { ResumePromptRepository.markPlayerEntered(it) }
|
|
}
|
|
PlayerScreen(
|
|
title = launch.title,
|
|
sourceUrl = launch.sourceUrl,
|
|
sourceAudioUrl = launch.sourceAudioUrl,
|
|
sourceHeaders = launch.sourceHeaders,
|
|
sourceResponseHeaders = launch.sourceResponseHeaders,
|
|
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,
|
|
initialPositionMs = launch.initialPositionMs,
|
|
initialProgressFraction = launch.initialProgressFraction,
|
|
onBack = {
|
|
ResumePromptRepository.markPlayerExitedNormally()
|
|
PlayerLaunchStore.remove(route.launchId)
|
|
navController.popBackStack()
|
|
},
|
|
modifier = Modifier.fillMaxSize(),
|
|
)
|
|
}
|
|
composable<CatalogRoute> { backStackEntry ->
|
|
val route = backStackEntry.toRoute<CatalogRoute>()
|
|
CatalogScreen(
|
|
title = route.title,
|
|
subtitle = route.subtitle,
|
|
manifestUrl = route.manifestUrl,
|
|
type = route.type,
|
|
catalogId = route.catalogId,
|
|
supportsPagination = route.supportsPagination,
|
|
genre = route.genre,
|
|
onBack = {
|
|
CatalogRepository.clear()
|
|
navController.popBackStack()
|
|
},
|
|
onPosterClick = { meta ->
|
|
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
|
},
|
|
modifier = Modifier.fillMaxSize(),
|
|
)
|
|
}
|
|
composable<HomescreenSettingsRoute> {
|
|
val onBack = rememberGuardedPopBackStack(
|
|
navController = navController,
|
|
backStackEntry = it,
|
|
)
|
|
HomescreenSettingsScreen(
|
|
onBack = onBack,
|
|
)
|
|
}
|
|
composable<MetaScreenSettingsRoute> { backStackEntry ->
|
|
val onBack = rememberGuardedPopBackStack(
|
|
navController = navController,
|
|
backStackEntry = backStackEntry,
|
|
)
|
|
MetaScreenSettingsScreen(
|
|
onBack = onBack,
|
|
)
|
|
}
|
|
composable<ContinueWatchingSettingsRoute> { backStackEntry ->
|
|
val onBack = rememberGuardedPopBackStack(
|
|
navController = navController,
|
|
backStackEntry = backStackEntry,
|
|
)
|
|
ContinueWatchingSettingsScreen(
|
|
onBack = onBack,
|
|
)
|
|
}
|
|
composable<DownloadsSettingsRoute> { backStackEntry ->
|
|
val onBack = rememberGuardedPopBackStack(
|
|
navController = navController,
|
|
backStackEntry = backStackEntry,
|
|
)
|
|
DownloadsScreen(
|
|
onBack = onBack,
|
|
onOpenDownload = { item ->
|
|
val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen
|
|
val resumeEntry = item.videoId
|
|
.takeIf { it.isNotBlank() }
|
|
?.let(WatchProgressRepository::progressForVideo)
|
|
?.takeIf { it.isResumable }
|
|
|
|
val launchId = PlayerLaunchStore.put(
|
|
PlayerLaunch(
|
|
title = item.title,
|
|
sourceUrl = sourceUrl,
|
|
sourceHeaders = emptyMap(),
|
|
sourceResponseHeaders = emptyMap(),
|
|
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 },
|
|
),
|
|
)
|
|
navController.navigate(PlayerRoute(launchId = launchId))
|
|
},
|
|
)
|
|
}
|
|
composable<AddonsSettingsRoute> { backStackEntry ->
|
|
val onBack = rememberGuardedPopBackStack(
|
|
navController = navController,
|
|
backStackEntry = backStackEntry,
|
|
)
|
|
AddonsSettingsScreen(
|
|
onBack = onBack,
|
|
)
|
|
}
|
|
if (AppFeaturePolicy.pluginsEnabled) {
|
|
composable<PluginsSettingsRoute> { backStackEntry ->
|
|
val onBack = rememberGuardedPopBackStack(
|
|
navController = navController,
|
|
backStackEntry = backStackEntry,
|
|
)
|
|
PluginsSettingsScreen(
|
|
onBack = onBack,
|
|
)
|
|
}
|
|
}
|
|
composable<AccountSettingsRoute> { backStackEntry ->
|
|
val onBack = rememberGuardedPopBackStack(
|
|
navController = navController,
|
|
backStackEntry = backStackEntry,
|
|
)
|
|
AccountSettingsScreen(
|
|
onBack = onBack,
|
|
)
|
|
}
|
|
composable<SupportersContributorsSettingsRoute> { backStackEntry ->
|
|
val onBack = rememberGuardedPopBackStack(
|
|
navController = navController,
|
|
backStackEntry = backStackEntry,
|
|
)
|
|
SupportersContributorsSettingsScreen(
|
|
onBack = onBack,
|
|
)
|
|
}
|
|
composable<LicensesAttributionsSettingsRoute> { backStackEntry ->
|
|
val onBack = rememberGuardedPopBackStack(
|
|
navController = navController,
|
|
backStackEntry = backStackEntry,
|
|
)
|
|
LicensesAttributionsSettingsScreen(
|
|
onBack = onBack,
|
|
)
|
|
}
|
|
composable<CollectionsRoute> { backStackEntry ->
|
|
val onBack = rememberGuardedPopBackStack(
|
|
navController = navController,
|
|
backStackEntry = backStackEntry,
|
|
)
|
|
CollectionManagementScreen(
|
|
onBack = onBack,
|
|
onNavigateToEditor = { collectionId ->
|
|
navController.navigate(CollectionEditorRoute(collectionId = collectionId))
|
|
},
|
|
)
|
|
}
|
|
composable<CollectionEditorRoute> { backStackEntry ->
|
|
val route = backStackEntry.toRoute<CollectionEditorRoute>()
|
|
CollectionEditorScreen(
|
|
collectionId = route.collectionId,
|
|
onBack = {
|
|
CollectionEditorRepository.clear()
|
|
navController.popBackStack()
|
|
},
|
|
)
|
|
}
|
|
composable<FolderDetailRoute> { backStackEntry ->
|
|
val route = backStackEntry.toRoute<FolderDetailRoute>()
|
|
LaunchedEffect(route.collectionId, route.folderId) {
|
|
FolderDetailRepository.initialize(route.collectionId, route.folderId)
|
|
}
|
|
FolderDetailScreen(
|
|
onBack = {
|
|
FolderDetailRepository.clear()
|
|
navController.popBackStack()
|
|
},
|
|
onCatalogClick = onCatalogClick,
|
|
onPosterClick = { meta ->
|
|
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
NuvioPosterActionSheet(
|
|
item = selectedPosterForActions,
|
|
isSaved = selectedPosterForActions?.let { preview ->
|
|
LibraryRepository.isSaved(preview.id, preview.type)
|
|
} == true,
|
|
isWatched = selectedPosterForActions?.let { preview ->
|
|
WatchingState.isPosterWatched(
|
|
watchedKeys = watchedUiState.watchedKeys,
|
|
item = preview,
|
|
)
|
|
} == true,
|
|
onDismiss = { selectedPosterForActions = null },
|
|
onToggleLibrary = {
|
|
selectedPosterForActions?.let { preview ->
|
|
val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L)
|
|
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
|
|
}
|
|
}
|
|
}
|
|
},
|
|
onToggleWatched = {
|
|
selectedPosterForActions?.let { preview ->
|
|
coroutineScope.launch {
|
|
WatchingActions.togglePosterWatched(preview)
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
NuvioContinueWatchingActionSheet(
|
|
item = selectedContinueWatchingForActions,
|
|
showManualPlayOption = StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState),
|
|
onDismiss = { selectedContinueWatchingForActions = null },
|
|
onOpenDetails = {
|
|
selectedContinueWatchingForActions?.let { item ->
|
|
navController.navigate(
|
|
DetailRoute(
|
|
type = item.parentMetaType,
|
|
id = item.parentMetaId,
|
|
),
|
|
)
|
|
}
|
|
},
|
|
onStartFromBeginning = selectedContinueWatchingForActions
|
|
?.takeIf { !it.isNextUp }
|
|
?.let { item -> { onContinueWatchingStartFromBeginning(item) } },
|
|
onPlayManually = selectedContinueWatchingForActions
|
|
?.let { item -> { onContinueWatchingPlayManually(item) } },
|
|
onRemove = {
|
|
selectedContinueWatchingForActions?.let { item ->
|
|
if (item.isNextUp) {
|
|
ContinueWatchingPreferencesRepository.addDismissedNextUpKey(
|
|
nextUpDismissKey(
|
|
item.parentMetaId,
|
|
item.nextUpSeedSeasonNumber,
|
|
item.nextUpSeedEpisodeNumber,
|
|
),
|
|
)
|
|
} else {
|
|
WatchProgressRepository.removeProgress(contentId = item.parentMetaId)
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
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(
|
|
visible = !initialHomeReady || profileSwitchLoading,
|
|
enter = fadeIn(),
|
|
exit = fadeOut(androidx.compose.animation.core.tween(400)),
|
|
) {
|
|
AppLaunchOverlay(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),
|
|
)
|
|
|
|
AppUpdaterHost(
|
|
controller = appUpdaterController,
|
|
modifier = Modifier
|
|
.align(Alignment.Center)
|
|
.zIndex(25f),
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun rememberGuardedPopBackStack(
|
|
navController: NavHostController,
|
|
backStackEntry: NavBackStackEntry,
|
|
beforePop: () -> Unit = {},
|
|
): () -> Unit {
|
|
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
|
var popHandled by remember(backStackEntry) { mutableStateOf(false) }
|
|
|
|
return remember(navController, backStackEntry, currentBackStackEntry, popHandled, beforePop) {
|
|
{
|
|
if (!popHandled && currentBackStackEntry == backStackEntry) {
|
|
popHandled = true
|
|
beforePop()
|
|
navController.popBackStack()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun AppTabHost(
|
|
selectedTab: AppScreenTab,
|
|
modifier: Modifier = Modifier,
|
|
searchFocusRequestCount: Int = 0,
|
|
animateHomeCollectionGifs: Boolean = true,
|
|
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
|
|
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
|
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
|
onLibraryPosterClick: ((LibraryItem) -> Unit)? = null,
|
|
onLibrarySectionViewAllClick: ((LibrarySection) -> Unit)? = null,
|
|
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
|
|
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null,
|
|
onSwitchProfile: (() -> Unit)? = null,
|
|
onHomescreenSettingsClick: () -> Unit = {},
|
|
onMetaScreenSettingsClick: () -> Unit = {},
|
|
onContinueWatchingSettingsClick: () -> Unit = {},
|
|
onDownloadsSettingsClick: () -> Unit = {},
|
|
onAddonsSettingsClick: () -> Unit = {},
|
|
onPluginsSettingsClick: () -> Unit = {},
|
|
onAccountSettingsClick: () -> Unit = {},
|
|
onSupportersContributorsSettingsClick: () -> Unit = {},
|
|
onLicensesAttributionsSettingsClick: () -> Unit = {},
|
|
onCheckForUpdatesClick: (() -> Unit)? = null,
|
|
onCollectionsSettingsClick: () -> Unit = {},
|
|
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
|
onInitialHomeContentRendered: () -> Unit = {},
|
|
) {
|
|
val tabStateHolder = rememberSaveableStateHolder()
|
|
|
|
Box(modifier = modifier.fillMaxSize()) {
|
|
tabStateHolder.SaveableStateProvider(selectedTab.name) {
|
|
when (selectedTab) {
|
|
AppScreenTab.Home -> {
|
|
HomeScreen(
|
|
modifier = Modifier.fillMaxSize(),
|
|
animateCollectionGifs = animateHomeCollectionGifs,
|
|
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,
|
|
)
|
|
}
|
|
|
|
AppScreenTab.Library -> {
|
|
LibraryScreen(
|
|
modifier = Modifier.fillMaxSize(),
|
|
onPosterClick = onLibraryPosterClick,
|
|
onSectionViewAllClick = onLibrarySectionViewAllClick,
|
|
)
|
|
}
|
|
|
|
AppScreenTab.Settings -> {
|
|
SettingsScreen(
|
|
modifier = Modifier.fillMaxSize(),
|
|
onSwitchProfile = onSwitchProfile,
|
|
onHomescreenClick = onHomescreenSettingsClick,
|
|
onMetaScreenClick = onMetaScreenSettingsClick,
|
|
onContinueWatchingClick = onContinueWatchingSettingsClick,
|
|
onDownloadsClick = onDownloadsSettingsClick,
|
|
onAddonsClick = onAddonsSettingsClick,
|
|
onPluginsClick = onPluginsSettingsClick,
|
|
onAccountClick = onAccountSettingsClick,
|
|
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
|
|
onLicensesAttributionsClick = onLicensesAttributionsSettingsClick,
|
|
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
|
onCollectionsClick = onCollectionsSettingsClick,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun TabletFloatingTopBar(
|
|
selectedTab: AppScreenTab,
|
|
onTabSelected: (AppScreenTab) -> Unit,
|
|
onProfileSelected: (NuvioProfile) -> Unit,
|
|
onAddProfileRequested: () -> Unit,
|
|
modifier: Modifier = Modifier,
|
|
) {
|
|
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
|
|
|
Box(
|
|
modifier = modifier
|
|
.fillMaxWidth()
|
|
.padding(top = statusBarPadding + 10.dp, bottom = 8.dp),
|
|
contentAlignment = Alignment.TopCenter,
|
|
) {
|
|
Surface(
|
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f),
|
|
shape = RoundedCornerShape(999.dp),
|
|
tonalElevation = 4.dp,
|
|
shadowElevation = 10.dp,
|
|
) {
|
|
Row(
|
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
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(18.dp),
|
|
tint = if (selectedTab == AppScreenTab.Home) {
|
|
MaterialTheme.colorScheme.onPrimaryContainer
|
|
} else {
|
|
MaterialTheme.colorScheme.onSurfaceVariant
|
|
},
|
|
)
|
|
},
|
|
)
|
|
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(18.dp),
|
|
tint = if (selectedTab == AppScreenTab.Search) {
|
|
MaterialTheme.colorScheme.onPrimaryContainer
|
|
} else {
|
|
MaterialTheme.colorScheme.onSurfaceVariant
|
|
},
|
|
)
|
|
},
|
|
)
|
|
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(18.dp),
|
|
tint = if (selectedTab == AppScreenTab.Library) {
|
|
MaterialTheme.colorScheme.onPrimaryContainer
|
|
} else {
|
|
MaterialTheme.colorScheme.onSurfaceVariant
|
|
},
|
|
)
|
|
},
|
|
)
|
|
Surface(
|
|
color = if (selectedTab == AppScreenTab.Settings) {
|
|
MaterialTheme.colorScheme.primaryContainer
|
|
} else {
|
|
MaterialTheme.colorScheme.surface
|
|
},
|
|
shape = RoundedCornerShape(999.dp),
|
|
) {
|
|
Row(
|
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
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) {
|
|
MaterialTheme.colorScheme.onPrimaryContainer
|
|
} else {
|
|
MaterialTheme.colorScheme.onSurfaceVariant
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun TabletTopPillItem(
|
|
label: String,
|
|
selected: Boolean,
|
|
onClick: () -> Unit,
|
|
icon: @Composable () -> Unit,
|
|
) {
|
|
Surface(
|
|
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
|
shape = RoundedCornerShape(999.dp),
|
|
tonalElevation = if (selected) 2.dp else 0.dp,
|
|
modifier = Modifier.clickable(onClick = onClick),
|
|
) {
|
|
Row(
|
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
) {
|
|
icon()
|
|
Text(
|
|
text = label,
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = if (selected) {
|
|
MaterialTheme.colorScheme.onPrimaryContainer
|
|
} else {
|
|
MaterialTheme.colorScheme.onSurfaceVariant
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun AppLaunchOverlay(
|
|
modifier: Modifier = Modifier,
|
|
) {
|
|
Box(
|
|
modifier = modifier
|
|
.background(MaterialTheme.colorScheme.background)
|
|
.zIndex(10f),
|
|
contentAlignment = Alignment.Center,
|
|
) {
|
|
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(24.dp))
|
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
|
}
|
|
}
|
|
}
|