mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-26 02:52:53 +00:00
1460 lines
68 KiB
Kotlin
1460 lines
68 KiB
Kotlin
package com.nuvio.app
|
|
|
|
import androidx.compose.animation.AnimatedContent
|
|
import androidx.compose.animation.core.animateFloatAsState
|
|
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.BoxScope
|
|
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.rounded.Home
|
|
import androidx.compose.material.icons.rounded.Search
|
|
import androidx.compose.material.icons.rounded.VideoLibrary
|
|
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.NavigationBar
|
|
import androidx.compose.material3.NavigationBarItem
|
|
import androidx.compose.material3.NavigationBarItemDefaults
|
|
import androidx.compose.material3.Scaffold
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.saveable.rememberSaveable
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.draw.alpha
|
|
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.compose.collectAsStateWithLifecycle
|
|
import androidx.navigation.compose.NavHost
|
|
import androidx.navigation.compose.composable
|
|
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 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.sync.SyncManager
|
|
import com.nuvio.app.core.ui.nuvioBottomNavigationBarInsets
|
|
import com.nuvio.app.core.ui.NuvioPosterActionSheet
|
|
import com.nuvio.app.core.ui.PlatformBackHandler
|
|
import com.nuvio.app.core.ui.TraktListPickerDialog
|
|
import com.nuvio.app.core.ui.NuvioTheme
|
|
import com.nuvio.app.features.auth.AuthScreen
|
|
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.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.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.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.ThemeSettingsRepository
|
|
import com.nuvio.app.features.streams.StreamContext
|
|
import com.nuvio.app.features.streams.StreamContextStore
|
|
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.TraktConnectionMode
|
|
import com.nuvio.app.features.trakt.TraktListTab
|
|
import com.nuvio.app.features.watched.WatchedRepository
|
|
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
|
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
|
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.Res
|
|
import nuvio.composeapp.generated.resources.app_logo_wordmark
|
|
import org.jetbrains.compose.resources.painterResource
|
|
|
|
@Serializable
|
|
object TabsRoute
|
|
|
|
@Serializable
|
|
data class DetailRoute(val type: String, val id: String)
|
|
|
|
@Serializable
|
|
data class PersonDetailRoute(
|
|
val personId: Int,
|
|
val personName: String,
|
|
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 AddonsSettingsRoute
|
|
|
|
@Serializable
|
|
object PluginsSettingsRoute
|
|
|
|
@Serializable
|
|
object AccountSettingsRoute
|
|
|
|
@Serializable
|
|
data class StreamRoute(
|
|
val type: String,
|
|
val videoId: String,
|
|
val parentMetaId: String? = null,
|
|
val parentMetaType: String? = null,
|
|
val title: String,
|
|
val logo: String? = null,
|
|
val poster: String? = null,
|
|
val background: String? = null,
|
|
val seasonNumber: Int? = null,
|
|
val episodeNumber: Int? = null,
|
|
val episodeTitle: String? = null,
|
|
val episodeThumbnail: String? = null,
|
|
val streamContextId: Long? = null,
|
|
val resumePositionMs: Long? = null,
|
|
val resumeProgressFraction: Float? = null,
|
|
)
|
|
|
|
@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 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)
|
|
.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()
|
|
}
|
|
|
|
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
|
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
|
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(authState) {
|
|
when (authState) {
|
|
is AuthState.Loading -> gateScreen = AppGateScreen.Loading.name
|
|
is AuthState.Unauthenticated -> {
|
|
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) {
|
|
autoSkipProfileSelection = true
|
|
val cachedProfiles = ProfileRepository.state.value.profiles
|
|
if (cachedProfiles.size == 1) {
|
|
val onlyProfile = cachedProfiles.first()
|
|
ProfileRepository.selectProfile(onlyProfile.profileIndex)
|
|
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
|
|
gateScreen = AppGateScreen.Main.name
|
|
autoSkipProfileSelection = false
|
|
} else {
|
|
gateScreen = AppGateScreen.ProfileSelection.name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
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)
|
|
@Composable
|
|
private fun MainAppContent(
|
|
onSwitchProfile: () -> Unit = {},
|
|
) {
|
|
val navController = rememberNavController()
|
|
remember {
|
|
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
|
}
|
|
val hapticFeedback = LocalHapticFeedback.current
|
|
val coroutineScope = rememberCoroutineScope()
|
|
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
|
|
var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(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 libraryUiState by remember {
|
|
LibraryRepository.ensureLoaded()
|
|
LibraryRepository.uiState
|
|
}.collectAsStateWithLifecycle()
|
|
val traktAuthUiState by remember {
|
|
TraktAuthRepository.ensureLoaded()
|
|
TraktAuthRepository.uiState
|
|
}.collectAsStateWithLifecycle()
|
|
val watchedUiState by remember {
|
|
WatchedRepository.ensureLoaded()
|
|
WatchedRepository.uiState
|
|
}.collectAsStateWithLifecycle()
|
|
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
|
|
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
|
|
LaunchedEffect(Unit) {
|
|
EpisodeReleaseNotificationsRepository.refreshAsync()
|
|
kotlinx.coroutines.delay(5_000)
|
|
initialHomeReady = true
|
|
}
|
|
var profileSwitchLoading by remember { mutableStateOf(false) }
|
|
|
|
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)
|
|
}
|
|
|
|
null -> Unit
|
|
}
|
|
}
|
|
}
|
|
|
|
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 ->
|
|
val streamContextId = pauseDescription
|
|
?.takeIf { it.isNotBlank() }
|
|
?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) }
|
|
navController.navigate(
|
|
StreamRoute(
|
|
type = type,
|
|
videoId = videoId,
|
|
parentMetaId = parentMetaId,
|
|
parentMetaType = parentMetaType,
|
|
title = title,
|
|
logo = logo,
|
|
poster = poster,
|
|
background = background,
|
|
seasonNumber = seasonNumber,
|
|
episodeNumber = episodeNumber,
|
|
episodeTitle = episodeTitle,
|
|
episodeThumbnail = episodeThumbnail,
|
|
streamContextId = streamContextId,
|
|
resumePositionMs = resumePositionMs,
|
|
resumeProgressFraction = null,
|
|
)
|
|
)
|
|
}
|
|
|
|
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 onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section ->
|
|
navController.navigate(
|
|
CatalogRoute(
|
|
title = section.displayTitle,
|
|
subtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) {
|
|
"Trakt Library"
|
|
} else {
|
|
"Library"
|
|
},
|
|
manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL,
|
|
type = section.items.firstOrNull()?.type ?: "movie",
|
|
catalogId = section.type,
|
|
supportsPagination = false,
|
|
),
|
|
)
|
|
}
|
|
|
|
val onContinueWatchingClick: (ContinueWatchingItem) -> Unit = { item ->
|
|
val streamContextId = item.pauseDescription
|
|
?.takeIf { it.isNotBlank() }
|
|
?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) }
|
|
navController.navigate(
|
|
StreamRoute(
|
|
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,
|
|
streamContextId = streamContextId,
|
|
resumePositionMs = item.resumePositionMs,
|
|
resumeProgressFraction = item.resumeProgressFraction,
|
|
),
|
|
)
|
|
}
|
|
|
|
val onContinueWatchingLongPress: (ContinueWatchingItem) -> Unit = { item ->
|
|
navController.navigate(
|
|
DetailRoute(
|
|
type = item.parentMetaType,
|
|
id = item.parentMetaId,
|
|
),
|
|
)
|
|
}
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(MaterialTheme.colorScheme.background),
|
|
) {
|
|
NavHost(
|
|
navController = navController,
|
|
startDestination = TabsRoute,
|
|
modifier = Modifier.fillMaxSize(),
|
|
) {
|
|
composable<TabsRoute> {
|
|
PlatformBackHandler(
|
|
enabled = selectedTab != AppScreenTab.Home,
|
|
onBack = { selectedTab = AppScreenTab.Home },
|
|
)
|
|
|
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
|
val isTabletLayout = maxWidth >= 768.dp
|
|
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 = MaterialTheme.colorScheme.background,
|
|
contentWindowInsets = WindowInsets(0),
|
|
bottomBar = {
|
|
if (!isTabletLayout) {
|
|
val navigationItemColors = NavigationBarItemDefaults.colors(
|
|
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
|
|
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
NavigationBar(
|
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
windowInsets = nuvioBottomNavigationBarInsets(),
|
|
) {
|
|
NavigationBarItem(
|
|
selected = selectedTab == AppScreenTab.Home,
|
|
onClick = { selectedTab = AppScreenTab.Home },
|
|
icon = { Icon(Icons.Rounded.Home, contentDescription = null) },
|
|
label = { Text("Home") },
|
|
colors = navigationItemColors,
|
|
)
|
|
NavigationBarItem(
|
|
selected = selectedTab == AppScreenTab.Search,
|
|
onClick = { selectedTab = AppScreenTab.Search },
|
|
icon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
|
label = { Text("Search") },
|
|
colors = navigationItemColors,
|
|
)
|
|
NavigationBarItem(
|
|
selected = selectedTab == AppScreenTab.Library,
|
|
onClick = { selectedTab = AppScreenTab.Library },
|
|
icon = { Icon(Icons.Rounded.VideoLibrary, contentDescription = null) },
|
|
label = { Text("Library") },
|
|
colors = navigationItemColors,
|
|
)
|
|
NavigationBarItem(
|
|
selected = selectedTab == AppScreenTab.Settings,
|
|
onClick = { selectedTab = AppScreenTab.Settings },
|
|
icon = {
|
|
ProfileSwitcherTab(
|
|
selected = selectedTab == AppScreenTab.Settings,
|
|
onClick = { selectedTab = AppScreenTab.Settings },
|
|
onProfileSelected = onProfileSelected,
|
|
onAddProfileRequested = onSwitchProfile,
|
|
)
|
|
},
|
|
label = { Text("Profile") },
|
|
colors = navigationItemColors,
|
|
)
|
|
}
|
|
}
|
|
},
|
|
) { innerPadding ->
|
|
Box(modifier = Modifier.fillMaxSize()) {
|
|
AppTabHost(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(innerPadding),
|
|
selectedTab = selectedTab,
|
|
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) },
|
|
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
|
|
onPluginsSettingsClick = {
|
|
if (AppFeaturePolicy.pluginsEnabled) {
|
|
navController.navigate(PluginsSettingsRoute)
|
|
}
|
|
},
|
|
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
|
|
onInitialHomeContentRendered = { initialHomeReady = true },
|
|
)
|
|
|
|
if (isTabletLayout) {
|
|
TabletFloatingTopBar(
|
|
selectedTab = selectedTab,
|
|
onTabSelected = { selectedTab = it },
|
|
onProfileSelected = onProfileSelected,
|
|
onAddProfileRequested = onSwitchProfile,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
composable<DetailRoute> { backStackEntry ->
|
|
val route = backStackEntry.toRoute<DetailRoute>()
|
|
MetaDetailsScreen(
|
|
type = route.type,
|
|
id = route.id,
|
|
onBack = {
|
|
navController.popBackStack()
|
|
},
|
|
onPlay = onPlay,
|
|
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 ->
|
|
val tmdbId = person.tmdbId
|
|
if (tmdbId != null && tmdbId > 0) {
|
|
navController.navigate(
|
|
PersonDetailRoute(
|
|
personId = tmdbId,
|
|
personName = person.name,
|
|
preferCrew = person.role?.let {
|
|
it.equals("Director", ignoreCase = true) ||
|
|
it.equals("Writer", ignoreCase = true) ||
|
|
it.equals("Creator", 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,
|
|
),
|
|
)
|
|
}
|
|
},
|
|
modifier = Modifier.fillMaxSize(),
|
|
)
|
|
}
|
|
composable<PersonDetailRoute> { backStackEntry ->
|
|
val route = backStackEntry.toRoute<PersonDetailRoute>()
|
|
PersonDetailScreen(
|
|
personId = route.personId,
|
|
personName = route.personName,
|
|
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,
|
|
),
|
|
)
|
|
}
|
|
},
|
|
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 pauseDescription = remember(route.streamContextId) {
|
|
route.streamContextId?.let { contextId ->
|
|
StreamContextStore.get(contextId)?.pauseDescription
|
|
}
|
|
}
|
|
val shouldResolveEpisodeVideoId =
|
|
route.type == "series" &&
|
|
route.parentMetaId != null &&
|
|
route.seasonNumber != null &&
|
|
route.episodeNumber != null
|
|
var effectiveVideoId by rememberSaveable(
|
|
route.videoId,
|
|
route.parentMetaId,
|
|
route.seasonNumber,
|
|
route.episodeNumber,
|
|
) {
|
|
mutableStateOf(route.videoId)
|
|
}
|
|
var hasResolvedVideoId by rememberSaveable(
|
|
route.videoId,
|
|
route.parentMetaId,
|
|
route.seasonNumber,
|
|
route.episodeNumber,
|
|
) {
|
|
mutableStateOf(!shouldResolveEpisodeVideoId)
|
|
}
|
|
|
|
LaunchedEffect(
|
|
route.videoId,
|
|
route.parentMetaId,
|
|
route.parentMetaType,
|
|
route.type,
|
|
route.seasonNumber,
|
|
route.episodeNumber,
|
|
) {
|
|
effectiveVideoId = route.videoId
|
|
if (!shouldResolveEpisodeVideoId) {
|
|
hasResolvedVideoId = true
|
|
return@LaunchedEffect
|
|
}
|
|
|
|
hasResolvedVideoId = false
|
|
val metaType = route.parentMetaType ?: route.type
|
|
val metaId = route.parentMetaId ?: return@LaunchedEffect
|
|
val resolvedVideoId = runCatching {
|
|
MetaDetailsRepository.fetch(metaType, metaId)
|
|
}.getOrNull()
|
|
?.videos
|
|
?.firstOrNull { video ->
|
|
video.season == route.seasonNumber &&
|
|
video.episode == route.episodeNumber
|
|
}
|
|
?.id
|
|
?.takeIf { it.isNotBlank() }
|
|
|
|
effectiveVideoId = resolvedVideoId ?: route.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(route.videoId, effectiveVideoId) { mutableStateOf(false) }
|
|
LaunchedEffect(effectiveVideoId, hasResolvedVideoId, playerSettings.streamReuseLastLinkEnabled) {
|
|
if (!hasResolvedVideoId) return@LaunchedEffect
|
|
if (reuseHandled) return@LaunchedEffect
|
|
reuseHandled = true
|
|
if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect
|
|
val cacheKey = StreamLinkCacheRepository.contentKey(route.type, effectiveVideoId)
|
|
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
|
|
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
|
|
if (cached != null) {
|
|
val launchId = PlayerLaunchStore.put(
|
|
PlayerLaunch(
|
|
title = route.title,
|
|
sourceUrl = cached.url,
|
|
logo = route.logo,
|
|
poster = route.poster,
|
|
background = route.background,
|
|
seasonNumber = route.seasonNumber,
|
|
episodeNumber = route.episodeNumber,
|
|
episodeTitle = route.episodeTitle,
|
|
episodeThumbnail = route.episodeThumbnail,
|
|
streamTitle = cached.streamName,
|
|
streamSubtitle = null,
|
|
pauseDescription = pauseDescription,
|
|
providerName = cached.addonName,
|
|
providerAddonId = cached.addonId,
|
|
contentType = route.type,
|
|
videoId = effectiveVideoId,
|
|
parentMetaId = route.parentMetaId ?: effectiveVideoId,
|
|
parentMetaType = route.parentMetaType ?: route.type,
|
|
initialPositionMs = route.resumePositionMs ?: 0L,
|
|
initialProgressFraction = route.resumeProgressFraction,
|
|
)
|
|
)
|
|
route.streamContextId?.let(StreamContextStore::remove)
|
|
navController.navigate(PlayerRoute(launchId = launchId)) {
|
|
popUpTo<StreamRoute> { inclusive = true }
|
|
}
|
|
}
|
|
}
|
|
|
|
val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
|
|
var autoPlayHandled by rememberSaveable(route.videoId, effectiveVideoId) { mutableStateOf(false) }
|
|
LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled) {
|
|
if (!reuseHandled) 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(route.type, effectiveVideoId)
|
|
StreamLinkCacheRepository.save(
|
|
contentKey = cacheKey,
|
|
url = sourceUrl,
|
|
streamName = stream.streamLabel,
|
|
addonName = stream.addonName,
|
|
addonId = stream.addonId,
|
|
filename = stream.behaviorHints.filename,
|
|
videoSize = stream.behaviorHints.videoSize,
|
|
)
|
|
}
|
|
val launchId = PlayerLaunchStore.put(
|
|
PlayerLaunch(
|
|
title = route.title,
|
|
sourceUrl = sourceUrl,
|
|
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
|
|
logo = route.logo,
|
|
poster = route.poster,
|
|
background = route.background,
|
|
seasonNumber = route.seasonNumber,
|
|
episodeNumber = route.episodeNumber,
|
|
episodeTitle = route.episodeTitle,
|
|
episodeThumbnail = route.episodeThumbnail,
|
|
streamTitle = stream.streamLabel,
|
|
streamSubtitle = stream.streamSubtitle,
|
|
pauseDescription = pauseDescription,
|
|
providerName = stream.addonName,
|
|
providerAddonId = stream.addonId,
|
|
contentType = route.type,
|
|
videoId = effectiveVideoId,
|
|
parentMetaId = route.parentMetaId ?: effectiveVideoId,
|
|
parentMetaType = route.parentMetaType ?: route.type,
|
|
initialPositionMs = route.resumePositionMs ?: 0L,
|
|
initialProgressFraction = route.resumeProgressFraction,
|
|
)
|
|
)
|
|
StreamsRepository.consumeAutoPlay()
|
|
route.streamContextId?.let(StreamContextStore::remove)
|
|
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 = route.type,
|
|
videoId = effectiveVideoId,
|
|
parentMetaId = route.parentMetaId ?: effectiveVideoId,
|
|
parentMetaType = route.parentMetaType ?: route.type,
|
|
title = route.title,
|
|
logo = route.logo,
|
|
poster = route.poster,
|
|
background = route.background,
|
|
seasonNumber = route.seasonNumber,
|
|
episodeNumber = route.episodeNumber,
|
|
episodeTitle = route.episodeTitle,
|
|
episodeThumbnail = route.episodeThumbnail,
|
|
resumePositionMs = route.resumePositionMs,
|
|
resumeProgressFraction = route.resumeProgressFraction,
|
|
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
|
|
val sourceUrl = stream.directPlaybackUrl
|
|
if (sourceUrl != null) {
|
|
// Persist for Reuse Last Link
|
|
if (playerSettings.streamReuseLastLinkEnabled) {
|
|
val cacheKey = StreamLinkCacheRepository.contentKey(route.type, effectiveVideoId)
|
|
StreamLinkCacheRepository.save(
|
|
contentKey = cacheKey,
|
|
url = sourceUrl,
|
|
streamName = stream.streamLabel,
|
|
addonName = stream.addonName,
|
|
addonId = stream.addonId,
|
|
filename = stream.behaviorHints.filename,
|
|
videoSize = stream.behaviorHints.videoSize,
|
|
)
|
|
}
|
|
val launchId = PlayerLaunchStore.put(
|
|
PlayerLaunch(
|
|
title = route.title,
|
|
sourceUrl = sourceUrl,
|
|
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
|
|
logo = route.logo,
|
|
poster = route.poster,
|
|
background = route.background,
|
|
seasonNumber = route.seasonNumber,
|
|
episodeNumber = route.episodeNumber,
|
|
episodeTitle = route.episodeTitle,
|
|
episodeThumbnail = route.episodeThumbnail,
|
|
streamTitle = stream.streamLabel,
|
|
streamSubtitle = stream.streamSubtitle,
|
|
pauseDescription = pauseDescription,
|
|
providerName = stream.addonName,
|
|
providerAddonId = stream.addonId,
|
|
contentType = route.type,
|
|
videoId = effectiveVideoId,
|
|
parentMetaId = route.parentMetaId ?: effectiveVideoId,
|
|
parentMetaType = route.parentMetaType ?: route.type,
|
|
initialPositionMs = resolvedResumePositionMs ?: 0L,
|
|
initialProgressFraction = resolvedResumeProgressFraction,
|
|
)
|
|
)
|
|
route.streamContextId?.let(StreamContextStore::remove)
|
|
navController.navigate(
|
|
PlayerRoute(launchId = launchId)
|
|
)
|
|
}
|
|
},
|
|
onBack = {
|
|
route.streamContextId?.let(StreamContextStore::remove)
|
|
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
|
|
}
|
|
PlayerScreen(
|
|
title = launch.title,
|
|
sourceUrl = launch.sourceUrl,
|
|
sourceAudioUrl = launch.sourceAudioUrl,
|
|
sourceHeaders = launch.sourceHeaders,
|
|
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,
|
|
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 = {
|
|
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> {
|
|
HomescreenSettingsScreen(
|
|
onBack = { navController.popBackStack() },
|
|
)
|
|
}
|
|
composable<MetaScreenSettingsRoute> {
|
|
MetaScreenSettingsScreen(
|
|
onBack = { navController.popBackStack() },
|
|
)
|
|
}
|
|
composable<ContinueWatchingSettingsRoute> {
|
|
ContinueWatchingSettingsScreen(
|
|
onBack = { navController.popBackStack() },
|
|
)
|
|
}
|
|
composable<AddonsSettingsRoute> {
|
|
AddonsSettingsScreen(
|
|
onBack = { navController.popBackStack() },
|
|
)
|
|
}
|
|
if (AppFeaturePolicy.pluginsEnabled) {
|
|
composable<PluginsSettingsRoute> {
|
|
PluginsSettingsScreen(
|
|
onBack = { navController.popBackStack() },
|
|
)
|
|
}
|
|
}
|
|
composable<AccountSettingsRoute> {
|
|
AccountSettingsScreen(
|
|
onBack = { navController.popBackStack() },
|
|
)
|
|
}
|
|
}
|
|
|
|
NuvioPosterActionSheet(
|
|
item = selectedPosterForActions,
|
|
isSaved = selectedPosterForActions?.let { preview ->
|
|
LibraryRepository.isSaved(preview.id)
|
|
} == 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 (!isTraktConnected) {
|
|
LibraryRepository.toggleSaved(libraryItem)
|
|
} else {
|
|
pickerItem = libraryItem
|
|
pickerTitle = preview.name
|
|
coroutineScope.launch {
|
|
pickerPending = true
|
|
pickerError = null
|
|
runCatching {
|
|
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
|
|
val tabs = LibraryRepository.traktListTabs()
|
|
pickerTabs = tabs
|
|
pickerMembership = tabs.associate { tab ->
|
|
tab.key to (snapshot[tab.key] == true)
|
|
}
|
|
showLibraryListPicker = true
|
|
}.onFailure { error ->
|
|
pickerError = error.message ?: "Failed to load Trakt lists"
|
|
}
|
|
pickerPending = false
|
|
}
|
|
}
|
|
}
|
|
},
|
|
onToggleWatched = {
|
|
selectedPosterForActions?.let { preview ->
|
|
coroutineScope.launch {
|
|
WatchingActions.togglePosterWatched(preview)
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
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 ?: "Failed to update Trakt lists"
|
|
}
|
|
pickerPending = 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun AppTabHost(
|
|
selectedTab: AppScreenTab,
|
|
modifier: Modifier = Modifier,
|
|
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 = {},
|
|
onAddonsSettingsClick: () -> Unit = {},
|
|
onPluginsSettingsClick: () -> Unit = {},
|
|
onAccountSettingsClick: () -> Unit = {},
|
|
onInitialHomeContentRendered: () -> Unit = {},
|
|
) {
|
|
Box(modifier = modifier.fillMaxSize()) {
|
|
keepAliveTab(
|
|
selected = selectedTab == AppScreenTab.Home,
|
|
) {
|
|
HomeScreen(
|
|
modifier = Modifier.fillMaxSize(),
|
|
onCatalogClick = onCatalogClick,
|
|
onPosterClick = onPosterClick,
|
|
onPosterLongClick = onPosterLongClick,
|
|
onContinueWatchingClick = onContinueWatchingClick,
|
|
onContinueWatchingLongPress = onContinueWatchingLongPress,
|
|
onFirstCatalogRendered = onInitialHomeContentRendered,
|
|
)
|
|
}
|
|
keepAliveTab(
|
|
selected = selectedTab == AppScreenTab.Search,
|
|
) {
|
|
SearchScreen(
|
|
modifier = Modifier.fillMaxSize(),
|
|
onPosterClick = onPosterClick,
|
|
onPosterLongClick = onPosterLongClick,
|
|
)
|
|
}
|
|
keepAliveTab(
|
|
selected = selectedTab == AppScreenTab.Library,
|
|
) {
|
|
LibraryScreen(
|
|
modifier = Modifier.fillMaxSize(),
|
|
onPosterClick = onLibraryPosterClick,
|
|
onSectionViewAllClick = onLibrarySectionViewAllClick,
|
|
)
|
|
}
|
|
keepAliveTab(
|
|
selected = selectedTab == AppScreenTab.Settings,
|
|
) {
|
|
SettingsScreen(
|
|
modifier = Modifier.fillMaxSize(),
|
|
onSwitchProfile = onSwitchProfile,
|
|
onHomescreenClick = onHomescreenSettingsClick,
|
|
onMetaScreenClick = onMetaScreenSettingsClick,
|
|
onContinueWatchingClick = onContinueWatchingSettingsClick,
|
|
onAddonsClick = onAddonsSettingsClick,
|
|
onPluginsClick = onPluginsSettingsClick,
|
|
onAccountClick = onAccountSettingsClick,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@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 = "Home",
|
|
selected = selectedTab == AppScreenTab.Home,
|
|
onClick = { onTabSelected(AppScreenTab.Home) },
|
|
icon = {
|
|
Icon(
|
|
imageVector = Icons.Rounded.Home,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(18.dp),
|
|
)
|
|
},
|
|
)
|
|
TabletTopPillItem(
|
|
label = "Search",
|
|
selected = selectedTab == AppScreenTab.Search,
|
|
onClick = { onTabSelected(AppScreenTab.Search) },
|
|
icon = {
|
|
Icon(
|
|
imageVector = Icons.Rounded.Search,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(18.dp),
|
|
)
|
|
},
|
|
)
|
|
TabletTopPillItem(
|
|
label = "Library",
|
|
selected = selectedTab == AppScreenTab.Library,
|
|
onClick = { onTabSelected(AppScreenTab.Library) },
|
|
icon = {
|
|
Icon(
|
|
imageVector = Icons.Rounded.VideoLibrary,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(18.dp),
|
|
)
|
|
},
|
|
)
|
|
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 = "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 = "Nuvio",
|
|
modifier = Modifier
|
|
.fillMaxWidth(0.48f)
|
|
.height(44.dp),
|
|
contentScale = ContentScale.Fit,
|
|
)
|
|
Spacer(modifier = Modifier.height(24.dp))
|
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun BoxScope.keepAliveTab(
|
|
selected: Boolean,
|
|
content: @Composable () -> Unit,
|
|
) {
|
|
val contentAlpha by animateFloatAsState(
|
|
targetValue = if (selected) 1f else 0f,
|
|
animationSpec = tween(durationMillis = 220),
|
|
label = "tab_content_alpha",
|
|
)
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.alpha(contentAlpha)
|
|
.zIndex(if (selected) 1f else 0f),
|
|
) {
|
|
content()
|
|
}
|
|
}
|