Merge branch 'nav3' into cmp-rewrite

This commit is contained in:
tapframe 2026-07-10 19:22:25 +05:30
commit d622d1384c
32 changed files with 2746 additions and 1182 deletions

View file

@ -423,7 +423,7 @@ kotlin {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.atomicfu)
implementation(libs.kmpalette.core)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation3.ui)
implementation(libs.kermit)
implementation(libs.supabase.postgrest)
implementation(libs.supabase.auth)

View file

@ -1,7 +1,9 @@
package com.nuvio.app.core.ui
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -11,3 +13,7 @@ internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp
internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 6.dp
@Composable
internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets = WindowInsets.navigationBars
@Composable
internal actual fun platformPhysicalTopInset(): Dp =
WindowInsets.statusBars.asPaddingValues().calculateTopPadding()

File diff suppressed because it is too large Load diff

View file

@ -72,6 +72,8 @@ import org.jetbrains.compose.resources.stringResource
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.nuvio.app.navigation.LocalNativeNavigationBarHidden
import com.nuvio.app.navigation.LocalUseNativeNavigation
@Composable
fun NuvioScreen(
@ -142,6 +144,20 @@ fun NuvioScreenHeader(
) {
val tokens = MaterialTheme.nuvio
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val nativeDetailNavigation = LocalUseNativeNavigation.current &&
!LocalNativeNavigationBarHidden.current &&
onBack != null
if (nativeDetailNavigation) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(bottom = NuvioTokens.Space.s4),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
content = actions,
)
return
}
val resolvedTopPadding = topPadding ?: if (includeStatusBarPadding) statusBarTop else NuvioTokens.Space.none
Box(
modifier = modifier.fillMaxWidth(),
@ -263,6 +279,8 @@ fun NuvioBackButton(
iconSize: Dp = NuvioTokens.Icon.md,
contentDescription: String = stringResource(Res.string.action_back),
) {
if (LocalUseNativeNavigation.current && !LocalNativeNavigationBarHidden.current) return
Box(
modifier = modifier
.size(buttonSize)

View file

@ -1,8 +1,23 @@
package com.nuvio.app.core.ui
import com.nuvio.app.features.profiles.AvatarRepository
import com.nuvio.app.features.profiles.AvatarCatalogItem
import com.nuvio.app.features.profiles.MAX_PROFILES
import com.nuvio.app.features.profiles.NuvioProfile
import com.nuvio.app.features.profiles.PinVerifyResult
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.profiles.profileAvatarImageUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
internal enum class NativeNavigationTab {
Home,
@ -20,17 +35,11 @@ internal enum class NativeNavigationTab {
internal object NativeTabBridge {
private val _requestedTabs = MutableSharedFlow<NativeNavigationTab>(extraBufferCapacity = 1)
val requestedTabs: SharedFlow<NativeNavigationTab> = _requestedTabs.asSharedFlow()
private val _profileTabLongPresses = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val profileTabLongPresses: SharedFlow<Unit> = _profileTabLongPresses.asSharedFlow()
fun requestTab(tabName: String) {
_requestedTabs.tryEmit(NativeNavigationTab.fromName(tabName))
}
fun requestProfileTabLongPress() {
_profileTabLongPresses.tryEmit(Unit)
}
fun publishSelectedTab(tab: NativeNavigationTab) {
publishNativeSelectedTab(tab.name)
}
@ -71,12 +80,113 @@ internal object NativeTabBridge {
}
}
fun nativeTabSelect(tabName: String) {
NativeTabBridge.requestTab(tabName)
data class NativeProfileOption(
val profileIndex: Int,
val name: String,
val avatarColorHex: String,
val avatarImageUrl: String?,
val avatarBackgroundColorHex: String?,
val pinEnabled: Boolean,
val active: Boolean,
)
data class NativeProfileSwitcherState(
val profiles: List<NativeProfileOption>,
val isLoaded: Boolean,
val canAddProfile: Boolean,
)
class NativeProfileSwitcherController {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val profileSelections = Channel<Int>(Channel.BUFFERED)
private val manageProfileRequests = Channel<Unit>(Channel.BUFFERED)
private var observationJob: Job? = null
internal val selectedProfileIndices = profileSelections.receiveAsFlow()
internal val requestedManageProfiles = manageProfileRequests.receiveAsFlow()
fun currentState(): NativeProfileSwitcherState = nativeState(
profilesLoaded = ProfileRepository.state.value.isLoaded,
profiles = ProfileRepository.state.value.profiles,
activeProfileIndex = ProfileRepository.state.value.activeProfile?.profileIndex,
avatarsById = AvatarRepository.avatars.value.associateBy { it.id },
)
fun observeState(callback: (NativeProfileSwitcherState) -> Unit) {
observationJob?.cancel()
observationJob = scope.launch {
combine(ProfileRepository.state, AvatarRepository.avatars) { state, avatars ->
nativeState(
profilesLoaded = state.isLoaded,
profiles = state.profiles,
activeProfileIndex = state.activeProfile?.profileIndex,
avatarsById = avatars.associateBy { it.id },
)
}.collect { callback(it) }
}
}
fun stopObserving() {
observationJob?.cancel()
observationJob = null
}
private fun nativeState(
profilesLoaded: Boolean,
profiles: List<NuvioProfile>,
activeProfileIndex: Int?,
avatarsById: Map<String, AvatarCatalogItem>,
): NativeProfileSwitcherState {
val options = profiles.map { profile ->
val avatar = profile.avatarId?.let(avatarsById::get)
NativeProfileOption(
profileIndex = profile.profileIndex,
name = profile.name,
avatarColorHex = profile.avatarColorHex,
avatarImageUrl = profileAvatarImageUrl(profile, avatar),
avatarBackgroundColorHex = avatar?.bgColor,
pinEnabled = profile.pinEnabled,
active = profile.profileIndex == activeProfileIndex,
)
}
return NativeProfileSwitcherState(
profiles = options,
isLoaded = profilesLoaded,
canAddProfile = profiles.size < MAX_PROFILES,
)
}
fun chooseProfile(
profileIndex: Int,
pin: String?,
completion: (PinVerifyResult) -> Unit,
) {
scope.launch {
val profile = ProfileRepository.state.value.profiles
.firstOrNull { it.profileIndex == profileIndex }
if (profile == null) {
completion(PinVerifyResult(message = null))
return@launch
}
val result = if (profile.pinEnabled) {
ProfileRepository.verifyPin(profileIndex, pin.orEmpty())
} else {
PinVerifyResult(unlocked = true)
}
if (result.unlocked) {
profileSelections.trySend(profileIndex)
}
completion(result)
}
}
fun requestManageProfiles() {
manageProfileRequests.trySend(Unit)
}
}
fun nativeProfileTabLongPress() {
NativeTabBridge.requestProfileTabLongPress()
fun nativeTabSelect(tabName: String) {
NativeTabBridge.requestTab(tabName)
}
internal expect fun isLiquidGlassNativeTabBarSupported(): Boolean

View file

@ -13,6 +13,10 @@ internal expect val nuvioBottomNavigationExtraVerticalPadding: Dp
@Composable
internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets
/** Physical display-safe top inset, excluding any enclosing native toolbar. */
@Composable
internal expect fun platformPhysicalTopInset(): Dp
internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp }
@Composable

View file

@ -7,12 +7,15 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
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.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.GridItemSpan
@ -58,6 +61,7 @@ import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.home.stableKey
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watching.application.WatchingState
import com.nuvio.app.navigation.LocalUseNativeNavigation
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
@ -235,6 +239,16 @@ private fun CatalogHeader(
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalUseNativeNavigation.current) {
Box(
modifier = modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.statusBars)
.height(44.dp),
)
return
}
Column(
modifier = modifier
.fillMaxWidth()

View file

@ -71,6 +71,7 @@ data class CollectionEditorUiState(
val traktTrendingResults: List<TraktPublicListSearchResult> = emptyList(),
val traktPopularResults: List<TraktPublicListSearchResult> = emptyList(),
val traktSearchError: String? = null,
val sourcePickerCompletionGeneration: Long = 0L,
)
enum class TmdbBuilderMode {
@ -725,6 +726,7 @@ object CollectionEditorRepository {
tmdbCompanyResults = emptyList(),
tmdbCollectionResults = emptyList(),
tmdbSearchError = null,
sourcePickerCompletionGeneration = _uiState.value.sourcePickerCompletionGeneration + 1L,
)
}
@ -833,6 +835,7 @@ object CollectionEditorRepository {
traktTitleInput = "",
traktSearchResults = emptyList(),
traktSearchError = null,
sourcePickerCompletionGeneration = state.sourcePickerCompletionGeneration + 1L,
)
}

View file

@ -49,6 +49,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -75,47 +76,87 @@ import sh.calvin.reorderable.ReorderableCollectionItemScope
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
enum class CollectionEditorPage {
Root,
FolderEditor,
CatalogPicker,
TmdbSourcePicker,
TraktSourcePicker,
}
fun disposeCollectionEditorPage(page: CollectionEditorPage) {
when (page) {
CollectionEditorPage.Root -> Unit
CollectionEditorPage.FolderEditor -> CollectionEditorRepository.cancelFolderEdit()
CollectionEditorPage.CatalogPicker -> CollectionEditorRepository.hideCatalogPicker()
CollectionEditorPage.TmdbSourcePicker -> CollectionEditorRepository.hideTmdbSourcePicker()
CollectionEditorPage.TraktSourcePicker -> CollectionEditorRepository.hideTraktSourcePicker()
}
}
private val autoDismissedPickerPages = setOf(
CollectionEditorPage.TmdbSourcePicker,
CollectionEditorPage.TraktSourcePicker,
)
private fun CollectionEditorUiState.activeEditorPage(): CollectionEditorPage = when {
showCatalogPicker -> CollectionEditorPage.CatalogPicker
showTmdbSourcePicker -> CollectionEditorPage.TmdbSourcePicker
showTraktSourcePicker -> CollectionEditorPage.TraktSourcePicker
showFolderEditor && editingFolder != null -> CollectionEditorPage.FolderEditor
else -> CollectionEditorPage.Root
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun CollectionEditorScreen(
collectionId: String?,
onBack: () -> Unit,
initialPage: CollectionEditorPage? = null,
initializeRepository: Boolean = true,
onNavigateToPage: ((page: CollectionEditorPage, title: String) -> Unit)? = null,
) {
val state by CollectionEditorRepository.uiState.collectAsState()
val bottomInset = nuvioSafeBottomPadding()
LaunchedEffect(collectionId) {
CollectionEditorRepository.initialize(collectionId)
LaunchedEffect(collectionId, initializeRepository) {
if (initializeRepository) {
CollectionEditorRepository.initialize(collectionId)
}
}
val page = initialPage ?: state.activeEditorPage()
val initialPickerCompletionGeneration = remember(initialPage) {
state.sourcePickerCompletionGeneration
}
LaunchedEffect(initialPage, state.sourcePickerCompletionGeneration) {
if (
initialPage in autoDismissedPickerPages &&
state.sourcePickerCompletionGeneration != initialPickerCompletionGeneration
) {
onBack()
}
}
if (
initialPage != null &&
page != CollectionEditorPage.Root &&
state.editingFolder == null
) {
LaunchedEffect(initialPage) { onBack() }
return
}
fun closePage(pageToClose: CollectionEditorPage, close: () -> Unit) {
close()
if (initialPage == pageToClose) {
onBack()
}
}
val editingFolder = state.editingFolder
if (state.showFolderEditor && editingFolder != null) {
if (state.showCatalogPicker) {
CatalogPickerScreen(
availableCatalogs = state.availableCatalogs,
selectedSources = editingFolder.resolvedCatalogSources,
onToggle = { CollectionEditorRepository.toggleCatalogSource(it) },
onBack = { CollectionEditorRepository.hideCatalogPicker() },
)
return
}
if (state.showTmdbSourcePicker) {
TmdbSourcePickerScreen(
state = state,
onBack = { CollectionEditorRepository.hideTmdbSourcePicker() },
)
return
}
if (state.showTraktSourcePicker) {
TraktSourcePickerScreen(
state = state,
onBack = { CollectionEditorRepository.hideTraktSourcePicker() },
)
return
}
if (page == CollectionEditorPage.FolderEditor && editingFolder != null) {
val genrePickerIndex = state.genrePickerSourceIndex
val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) }
val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource()
@ -125,7 +166,18 @@ fun CollectionEditorScreen(
FolderEditorPage(
state = state,
onBack = { CollectionEditorRepository.cancelFolderEdit() },
onBack = {
closePage(CollectionEditorPage.FolderEditor) {
CollectionEditorRepository.cancelFolderEdit()
}
},
onNavigateToPage = onNavigateToPage,
onSave = {
CollectionEditorRepository.saveFolderEdit()
if (initialPage == CollectionEditorPage.FolderEditor) {
onBack()
}
},
)
if (
@ -149,28 +201,40 @@ fun CollectionEditorScreen(
return
}
if (state.showCatalogPicker) {
if (page == CollectionEditorPage.CatalogPicker) {
CatalogPickerScreen(
availableCatalogs = state.availableCatalogs,
selectedSources = state.editingFolder?.resolvedCatalogSources.orEmpty(),
onToggle = { CollectionEditorRepository.toggleCatalogSource(it) },
onBack = { CollectionEditorRepository.hideCatalogPicker() },
onBack = {
closePage(CollectionEditorPage.CatalogPicker) {
CollectionEditorRepository.hideCatalogPicker()
}
},
)
return
}
if (state.showTmdbSourcePicker) {
if (page == CollectionEditorPage.TmdbSourcePicker) {
TmdbSourcePickerScreen(
state = state,
onBack = { CollectionEditorRepository.hideTmdbSourcePicker() },
onBack = {
closePage(CollectionEditorPage.TmdbSourcePicker) {
CollectionEditorRepository.hideTmdbSourcePicker()
}
},
)
return
}
if (state.showTraktSourcePicker) {
if (page == CollectionEditorPage.TraktSourcePicker) {
TraktSourcePickerScreen(
state = state,
onBack = { CollectionEditorRepository.hideTraktSourcePicker() },
onBack = {
closePage(CollectionEditorPage.TraktSourcePicker) {
CollectionEditorRepository.hideTraktSourcePicker()
}
},
)
return
}
@ -335,7 +399,10 @@ fun CollectionEditorScreen(
) {
NuvioSectionLabel(text = stringResource(Res.string.collections_editor_folders))
TextButton(
onClick = { CollectionEditorRepository.addFolder(newFolderTitle) },
onClick = {
CollectionEditorRepository.addFolder(newFolderTitle)
onNavigateToPage?.invoke(CollectionEditorPage.FolderEditor, newFolderTitle)
},
) {
Icon(
imageVector = Icons.Rounded.Add,
@ -351,9 +418,13 @@ fun CollectionEditorScreen(
// Folder Items
if (state.folders.isNotEmpty()) {
item {
val editFolderTitle = stringResource(Res.string.collections_editor_edit_folder)
FolderReorderableList(
folders = state.folders,
onEdit = { CollectionEditorRepository.editFolder(it) },
onEdit = {
CollectionEditorRepository.editFolder(it)
onNavigateToPage?.invoke(CollectionEditorPage.FolderEditor, editFolderTitle)
},
onDelete = { CollectionEditorRepository.removeFolder(it) },
)
}
@ -558,9 +629,15 @@ private fun FolderListItem(
private fun FolderEditorPage(
state: CollectionEditorUiState,
onBack: () -> Unit,
onNavigateToPage: ((page: CollectionEditorPage, title: String) -> Unit)?,
onSave: () -> Unit,
) {
val folder = state.editingFolder ?: return
val bottomInset = nuvioSafeBottomPadding()
val catalogPickerTitle = stringResource(Res.string.collections_editor_select_catalogs)
val tmdbSourcePickerTitle = stringResource(Res.string.collections_editor_tmdb_sources)
val traktSourcePickerTitle = stringResource(Res.string.collections_editor_trakt_sources)
val editTraktSourcePickerTitle = stringResource(Res.string.collections_editor_edit_trakt_source)
PlatformBackHandler(enabled = true) {
onBack()
@ -725,7 +802,15 @@ private fun FolderEditorPage(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) {
TextButton(
onClick = {
CollectionEditorRepository.showTmdbSourcePicker()
onNavigateToPage?.invoke(
CollectionEditorPage.TmdbSourcePicker,
tmdbSourcePickerTitle,
)
},
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
@ -734,7 +819,15 @@ private fun FolderEditorPage(
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(Res.string.source_tmdb))
}
TextButton(onClick = { CollectionEditorRepository.showTraktSourcePicker() }) {
TextButton(
onClick = {
CollectionEditorRepository.showTraktSourcePicker()
onNavigateToPage?.invoke(
CollectionEditorPage.TraktSourcePicker,
traktSourcePickerTitle,
)
},
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
@ -743,7 +836,15 @@ private fun FolderEditorPage(
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(Res.string.collections_editor_add_trakt_source))
}
TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) {
TextButton(
onClick = {
CollectionEditorRepository.showCatalogPicker()
onNavigateToPage?.invoke(
CollectionEditorPage.CatalogPicker,
catalogPickerTitle,
)
},
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
@ -784,7 +885,13 @@ private fun FolderEditorPage(
} else if (source.isTrakt) {
FolderTraktSourceCard(
source = source,
onEdit = { CollectionEditorRepository.editTraktSource(index) },
onEdit = {
CollectionEditorRepository.editTraktSource(index)
onNavigateToPage?.invoke(
CollectionEditorPage.TraktSourcePicker,
editTraktSourcePickerTitle,
)
},
onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
)
} else if (addonSource != null) {
@ -823,7 +930,7 @@ private fun FolderEditorPage(
NuvioPrimaryButton(
text = stringResource(Res.string.collections_editor_save),
enabled = folder.title.isNotBlank(),
onClick = { CollectionEditorRepository.saveFolderEdit() },
onClick = onSave,
)
}
}

View file

@ -73,6 +73,7 @@ import com.nuvio.app.features.watchprogress.CurrentDateProvider
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import com.nuvio.app.navigation.LocalUseNativeNavigation
private sealed interface PersonDetailUiState {
data object Loading : PersonDetailUiState
@ -146,19 +147,20 @@ fun PersonDetailScreen(
)
}
// Back button overlaid on top
IconButton(
onClick = onBack,
modifier = Modifier
.windowInsetsPadding(WindowInsets.statusBars)
.padding(start = 4.dp, top = 4.dp)
.align(Alignment.TopStart),
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(Res.string.action_back),
tint = MaterialTheme.colorScheme.onSurface,
)
if (!LocalUseNativeNavigation.current) {
IconButton(
onClick = onBack,
modifier = Modifier
.windowInsetsPadding(WindowInsets.statusBars)
.padding(start = 4.dp, top = 4.dp)
.align(Alignment.TopStart),
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(Res.string.action_back),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}

View file

@ -62,6 +62,7 @@ import com.nuvio.app.features.tmdb.TmdbEntityMediaType
import com.nuvio.app.features.tmdb.TmdbEntityRailType
import com.nuvio.app.features.tmdb.TmdbMetadataService
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.navigation.LocalUseNativeNavigation
private sealed interface EntityBrowseUiState {
data object Loading : EntityBrowseUiState
@ -126,18 +127,20 @@ fun TmdbEntityBrowseScreen(
}
}
IconButton(
onClick = onBack,
modifier = Modifier
.windowInsetsPadding(WindowInsets.statusBars)
.padding(start = 4.dp, top = 4.dp)
.align(Alignment.TopStart),
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(Res.string.action_back),
tint = MaterialTheme.colorScheme.onSurface,
)
if (!LocalUseNativeNavigation.current) {
IconButton(
onClick = onBack,
modifier = Modifier
.windowInsetsPadding(WindowInsets.statusBars)
.padding(start = 4.dp, top = 4.dp)
.align(Alignment.TopStart),
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(Res.string.action_back),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}

View file

@ -39,8 +39,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.core.ui.platformPhysicalTopInset
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.isIos
import com.nuvio.app.navigation.LocalUseNativeNavigation
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@ -54,7 +56,12 @@ fun DetailFloatingHeader(
onToggleSaved: () -> Unit,
modifier: Modifier = Modifier,
) {
val safeAreaTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val useNativeNavigation = LocalUseNativeNavigation.current
val safeAreaTop = if (useNativeNavigation) {
platformPhysicalTopInset()
} else {
WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
}
val headerTopPadding = (safeAreaTop - 6.dp).coerceAtLeast(safeAreaTop * 0.8f)
val interactive = progress > 0.05f
val surfaceColor = backgroundColor ?: if (isIos) {
@ -93,7 +100,7 @@ fun DetailFloatingHeader(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (interactive) {
if (interactive && !useNativeNavigation) {
NuvioBackButton(
onClick = onBack,
modifier = Modifier.size(40.dp),
@ -103,6 +110,9 @@ fun DetailFloatingHeader(
iconSize = 24.dp,
)
} else {
// Native iOS navigation owns the back button, but retaining
// this slot keeps the Compose logo centered as the floating
// header replaces the hero while scrolling.
Box(modifier = Modifier.size(40.dp))
}

View file

@ -47,13 +47,16 @@ import org.jetbrains.compose.resources.stringResource
fun DownloadsScreen(
onBack: () -> Unit,
onOpenDownload: (DownloadItem) -> Unit,
initialShowId: String? = null,
onNavigateToShow: ((showId: String, title: String) -> Unit)? = null,
onBackFromShow: (() -> Unit)? = null,
) {
val uiState by remember {
DownloadsRepository.ensureLoaded()
DownloadsRepository.uiState
}.collectAsStateWithLifecycle()
var selectedShowId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedShowId by rememberSaveable(initialShowId) { mutableStateOf(initialShowId) }
val openDownloadsDirectoryFailedText = stringResource(Res.string.downloads_open_directory_failed)
val completedEpisodes = remember(uiState.items) {
@ -78,7 +81,7 @@ fun DownloadsScreen(
},
onBack = {
if (selectedShowId != null) {
selectedShowId = null
onBackFromShow?.invoke() ?: run { selectedShowId = null }
} else {
onBack()
}
@ -104,7 +107,9 @@ fun DownloadsScreen(
downloadsRootContent(
uiState = uiState,
onOpenDownload = onOpenDownload,
onOpenShow = { showId -> selectedShowId = showId },
onOpenShow = { showId, title ->
onNavigateToShow?.invoke(showId, title) ?: run { selectedShowId = showId }
},
)
} else {
downloadsShowContent(
@ -119,7 +124,7 @@ fun DownloadsScreen(
private fun LazyListScope.downloadsRootContent(
uiState: DownloadsUiState,
onOpenDownload: (DownloadItem) -> Unit,
onOpenShow: (String) -> Unit,
onOpenShow: (showId: String, title: String) -> Unit,
) {
val activeItems = uiState.activeItems
val completedMovies = uiState.completedItems.filterNot(DownloadItem::isEpisode)
@ -183,7 +188,7 @@ private fun LazyListScope.downloadsRootContent(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp)
.clickable { onOpenShow(item.parentMetaId) },
.clickable { onOpenShow(item.parentMetaId, item.title) },
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainer,
) {

View file

@ -1,7 +1,6 @@
package com.nuvio.app.features.player
import androidx.compose.runtime.Composable
import kotlinx.serialization.Serializable
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.player_ios_hardware_decoder_off
import nuvio.composeapp.generated.resources.player_ios_preset_compatibility_desc
@ -14,11 +13,6 @@ import nuvio.composeapp.generated.resources.player_ios_preset_sdr_tone_mapped_de
import nuvio.composeapp.generated.resources.player_ios_preset_sdr_tone_mapped_label
import org.jetbrains.compose.resources.stringResource
@Serializable
data class PlayerRoute(
val launchId: Long,
)
data class PlayerLaunch(
val profileId: Int,
val title: String,

View file

@ -80,6 +80,7 @@ import com.nuvio.app.features.tmdb.TmdbSettings
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState
import com.nuvio.app.navigation.LocalUseNativeNavigation
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.compose_settings_page_root
import kotlinx.coroutines.delay
@ -99,13 +100,26 @@ private fun SettingsPage.isEnabledByPolicy(): Boolean =
else -> true
}
@Composable
private fun settingsPageTitles(): Map<SettingsPage, String> {
val titles = mutableMapOf<SettingsPage, String>()
for (page in SettingsPage.entries) {
titles[page] = stringResource(page.titleRes)
}
return titles
}
@Composable
fun SettingsScreen(
modifier: Modifier = Modifier,
rootActionRequests: Flow<Unit> = emptyFlow(),
initialPageName: String = SettingsPage.Root.name,
requestedPageName: String? = null,
onRequestedPageConsumed: () -> Unit = {},
rootActionsEnabled: Boolean = true,
onNavigatePage: ((pageName: String, title: String) -> Unit)? = null,
onExternalBack: (() -> Unit)? = null,
showInternalHeader: Boolean = true,
onSwitchProfile: (() -> Unit)? = null,
onHomescreenClick: () -> Unit = {},
onMetaScreenClick: () -> Unit = {},
@ -135,7 +149,10 @@ fun SettingsScreen(
val liquidGlassNativeTabBarEnabled by remember {
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
}.collectAsStateWithLifecycle()
val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() }
val useNativeNavigation = LocalUseNativeNavigation.current
val liquidGlassNativeTabBarSupported = remember(useNativeNavigation) {
!useNativeNavigation && isLiquidGlassNativeTabBarSupported()
}
val selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle()
val tmdbSettings by remember {
TmdbSettingsRepository.ensureLoaded()
@ -219,8 +236,15 @@ fun SettingsScreen(
HomeCatalogSettingsRepository.syncCollections(collections)
}
var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) }
val initialPage = remember(initialPageName) {
runCatching { SettingsPage.valueOf(initialPageName) }
.getOrDefault(SettingsPage.Root)
.takeIf { it.isEnabledByPolicy() }
?: SettingsPage.Root
}
var currentPage by rememberSaveable(initialPageName) { mutableStateOf(initialPage.name) }
val scrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val pageTitles = settingsPageTitles()
val page = remember(currentPage) {
runCatching { SettingsPage.valueOf(currentPage) }
.getOrDefault(SettingsPage.Root)
@ -229,6 +253,73 @@ fun SettingsScreen(
}
val previousPage = page.previousPage()
fun openPage(targetPage: SettingsPage) {
if (!targetPage.isEnabledByPolicy()) return
val externalNavigator = onNavigatePage
if (externalNavigator == null) {
currentPage = targetPage.name
return
}
if (targetPage == SettingsPage.Root && onExternalBack != null) {
onExternalBack()
return
}
externalNavigator(
targetPage.name,
pageTitles.getValue(targetPage),
)
}
fun navigateBack() {
val parentPage = previousPage ?: return
if (onNavigatePage != null && onExternalBack != null) {
onExternalBack()
} else {
currentPage = parentPage.name
}
}
val openHomescreen = if (onNavigatePage != null) {
{ openPage(SettingsPage.Homescreen) }
} else {
onHomescreenClick
}
val openMetaScreen = if (onNavigatePage != null) {
{ openPage(SettingsPage.MetaScreen) }
} else {
onMetaScreenClick
}
val openContinueWatching = if (onNavigatePage != null) {
{ openPage(SettingsPage.ContinueWatching) }
} else {
onContinueWatchingClick
}
val openAddons = if (onNavigatePage != null) {
{ openPage(SettingsPage.Addons) }
} else {
onAddonsClick
}
val openPlugins = if (onNavigatePage != null) {
{ openPage(SettingsPage.Plugins) }
} else {
onPluginsClick
}
val openAccount = if (onNavigatePage != null) {
{ openPage(SettingsPage.Account) }
} else {
onAccountClick
}
val openSupportersContributors = if (onNavigatePage != null) {
{ openPage(SettingsPage.SupportersContributors) }
} else {
onSupportersContributorsClick
}
val openLicensesAttributions = if (onNavigatePage != null) {
{ openPage(SettingsPage.LicensesAttributions) }
} else {
onLicensesAttributionsClick
}
LaunchedEffect(page, currentPage) {
if (page.name != currentPage) {
currentPage = page.name
@ -240,7 +331,7 @@ fun SettingsScreen(
if (!rootActionsEnabled) return@collect
val pageToOpen = page.previousPage()
if (pageToOpen != null) {
currentPage = pageToOpen.name
navigateBack()
} else {
scrollToTopRequests.tryEmit(Unit)
}
@ -255,20 +346,22 @@ fun SettingsScreen(
return@LaunchedEffect
}
if (!rootActionsEnabled) return@LaunchedEffect
currentPage = targetPage.name
openPage(targetPage)
onRequestedPageConsumed()
}
PlatformBackHandler(
enabled = rootActionsEnabled && previousPage != null,
onBack = { previousPage?.let { currentPage = it.name } },
enabled = previousPage != null && (rootActionsEnabled || onExternalBack != null),
onBack = ::navigateBack,
)
if (maxWidth >= 768.dp) {
TabletSettingsScreen(
page = page,
scrollToTopRequests = scrollToTopRequests,
onPageChange = { currentPage = it.name },
onPageChange = ::openPage,
onNavigateBack = ::navigateBack,
showInternalHeader = showInternalHeader,
showLoadingOverlay = playerSettingsUiState.showLoadingOverlay,
holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled,
holdToSpeedValue = playerSettingsUiState.holdToSpeedValue,
@ -314,8 +407,8 @@ fun SettingsScreen(
posterCardStyleUiState = posterCardStyleUiState,
onSwitchProfile = onSwitchProfile,
onDownloadsClick = onDownloadsClick,
onSupportersContributorsClick = onSupportersContributorsClick,
onLicensesAttributionsClick = onLicensesAttributionsClick,
onSupportersContributorsClick = openSupportersContributors,
onLicensesAttributionsClick = openLicensesAttributions,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick,
)
@ -323,7 +416,9 @@ fun SettingsScreen(
MobileSettingsScreen(
page = page,
scrollToTopRequests = scrollToTopRequests,
onPageChange = { currentPage = it.name },
onPageChange = ::openPage,
onNavigateBack = ::navigateBack,
showInternalHeader = showInternalHeader,
showLoadingOverlay = playerSettingsUiState.showLoadingOverlay,
holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled,
holdToSpeedValue = playerSettingsUiState.holdToSpeedValue,
@ -368,15 +463,15 @@ fun SettingsScreen(
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
posterCardStyleUiState = posterCardStyleUiState,
onSwitchProfile = onSwitchProfile,
onHomescreenClick = onHomescreenClick,
onMetaScreenClick = onMetaScreenClick,
onContinueWatchingClick = onContinueWatchingClick,
onAddonsClick = onAddonsClick,
onPluginsClick = onPluginsClick,
onHomescreenClick = openHomescreen,
onMetaScreenClick = openMetaScreen,
onContinueWatchingClick = openContinueWatching,
onAddonsClick = openAddons,
onPluginsClick = openPlugins,
onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick,
onSupportersContributorsClick = onSupportersContributorsClick,
onLicensesAttributionsClick = onLicensesAttributionsClick,
onAccountClick = openAccount,
onSupportersContributorsClick = openSupportersContributors,
onLicensesAttributionsClick = openLicensesAttributions,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick,
)
@ -389,6 +484,8 @@ private fun MobileSettingsScreen(
page: SettingsPage,
scrollToTopRequests: Flow<Unit>,
onPageChange: (SettingsPage) -> Unit,
onNavigateBack: () -> Unit,
showInternalHeader: Boolean,
showLoadingOverlay: Boolean,
holdToSpeedEnabled: Boolean,
holdToSpeedValue: Float,
@ -521,12 +618,16 @@ private fun MobileSettingsScreen(
modifier = Modifier.nestedScroll(rootSearchRevealConnection),
listState = listState,
) {
stickyHeader {
val previousPage = page.previousPage()
NuvioScreenHeader(
title = stringResource(page.titleRes),
onBack = previousPage?.let { { onPageChange(it) } },
)
if (showInternalHeader) {
stickyHeader {
val previousPage = page.previousPage()
NuvioScreenHeader(
title = stringResource(page.titleRes),
onBack = previousPage?.let { { onNavigateBack() } },
)
}
} else {
item { Spacer(modifier = Modifier.height(44.dp)) }
}
when (page) {
@ -733,6 +834,8 @@ private fun TabletSettingsScreen(
page: SettingsPage,
scrollToTopRequests: Flow<Unit>,
onPageChange: (SettingsPage) -> Unit,
onNavigateBack: () -> Unit,
showInternalHeader: Boolean,
showLoadingOverlay: Boolean,
holdToSpeedEnabled: Boolean,
holdToSpeedValue: Float,
@ -911,21 +1014,23 @@ private fun TabletSettingsScreen(
),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
val previousPage = page.previousPage()
TabletPageHeader(
title = if (page == SettingsPage.Root) {
if (settingsSearchQuery.isBlank()) {
stringResource(activeCategory.labelRes)
if (showInternalHeader) {
item {
val previousPage = page.previousPage()
TabletPageHeader(
title = if (page == SettingsPage.Root) {
if (settingsSearchQuery.isBlank()) {
stringResource(activeCategory.labelRes)
} else {
stringResource(Res.string.compose_settings_page_root)
}
} else {
stringResource(Res.string.compose_settings_page_root)
}
} else {
stringResource(page.titleRes)
},
showBack = previousPage != null,
onBack = { previousPage?.let(onPageChange) },
)
stringResource(page.titleRes)
},
showBack = previousPage != null,
onBack = onNavigateBack,
)
}
}
when (page) {
SettingsPage.Root -> {

View file

@ -35,7 +35,6 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
@ -61,6 +60,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@ -90,6 +90,7 @@ import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
import com.nuvio.app.features.debrid.toastMessage
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.navigation.LocalUseNativeNavigation
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import nuvio.composeapp.generated.resources.*
@ -127,6 +128,7 @@ fun StreamsScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val useNativeNavigation = LocalUseNativeNavigation.current
val uiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
val playerSettings by remember {
PlayerSettingsRepository.ensureLoaded()
@ -205,6 +207,16 @@ fun StreamsScreen(
} else {
background ?: poster
}
val reloadStreams: () -> Unit = {
StreamsRepository.reload(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
season = seasonNumber,
episode = episodeNumber,
manualSelection = manualSelection,
)
}
BoxWithConstraints(
modifier = modifier
@ -233,6 +245,7 @@ fun StreamsScreen(
onStreamSelected(stream, positionMs, progressFraction)
},
onStreamLongPress = { stream -> streamActionsTarget = stream },
onRefresh = reloadStreams,
)
} else {
MobileStreamsLayout(
@ -252,6 +265,7 @@ fun StreamsScreen(
onStreamSelected(stream, positionMs, progressFraction)
},
onStreamLongPress = { stream -> streamActionsTarget = stream },
onRefresh = reloadStreams,
)
}
@ -259,7 +273,7 @@ fun StreamsScreen(
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top))
.padding(start = 12.dp, top = 8.dp),
.padding(start = 12.dp, top = if (useNativeNavigation) 52.dp else 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
NuvioBackButton(
@ -269,35 +283,6 @@ fun StreamsScreen(
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.45f),
contentColor = MaterialTheme.colorScheme.onBackground,
)
Box(
modifier = Modifier
.size(40.dp)
.background(
color = MaterialTheme.colorScheme.background.copy(alpha = 0.45f),
shape = CircleShape,
)
.clickable(
onClick = {
StreamsRepository.reload(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
season = seasonNumber,
episode = episodeNumber,
manualSelection = manualSelection,
)
},
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = stringResource(Res.string.streams_refresh),
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.size(20.dp),
)
}
}
AnimatedVisibility(
@ -470,6 +455,7 @@ private fun MobileStreamsLayout(
resumeProgressFraction: Float?,
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
onStreamLongPress: (StreamItem) -> Unit,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
@ -542,6 +528,7 @@ private fun MobileStreamsLayout(
groups = uiState.groups,
selectedFilter = uiState.selectedFilter,
onFilterSelected = { addonId -> StreamsRepository.selectFilter(addonId) },
onRefresh = onRefresh,
)
StreamList(
@ -751,10 +738,10 @@ internal fun ProviderFilterRow(
groups: List<AddonStreamGroup>,
selectedFilter: String?,
onFilterSelected: (String?) -> Unit,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
) {
val addonGroups = groups.filter { it.streams.isNotEmpty() || it.isLoading }
if (addonGroups.isEmpty()) return
Row(
modifier = modifier
@ -763,6 +750,12 @@ internal fun ProviderFilterRow(
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
icon = Icons.Rounded.Refresh,
contentDescription = stringResource(Res.string.streams_refresh),
isSelected = false,
onClick = onRefresh,
)
// "All" chip
FilterChip(
label = stringResource(Res.string.collections_tab_all),
@ -781,7 +774,9 @@ internal fun ProviderFilterRow(
@Composable
private fun FilterChip(
label: String,
label: String? = null,
icon: ImageVector? = null,
contentDescription: String? = null,
isSelected: Boolean,
onClick: () -> Unit,
) {
@ -816,6 +811,7 @@ private fun FilterChip(
scaleX = scale
scaleY = scale
}
.height(36.dp)
.clip(RoundedCornerShape(16.dp))
.background(containerColor)
.clickable(
@ -823,18 +819,34 @@ private fun FilterChip(
indication = null,
onClick = onClick,
)
.padding(horizontal = 14.dp, vertical = 8.dp),
.padding(horizontal = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium.copy(
fontSize = 14.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
letterSpacing = 0.1.sp,
),
color = contentColor,
maxLines = 1,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = contentColor,
modifier = Modifier.size(20.dp),
)
}
if (label != null) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium.copy(
fontSize = 14.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
letterSpacing = 0.1.sp,
),
color = contentColor,
maxLines = 1,
)
}
}
}
}

View file

@ -67,6 +67,7 @@ internal fun TabletStreamsLayout(
resumeProgressFraction: Float?,
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
onStreamLongPress: (StreamItem) -> Unit,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
) {
val hazeState = rememberHazeState()
@ -196,6 +197,7 @@ internal fun TabletStreamsLayout(
groups = uiState.groups,
selectedFilter = uiState.selectedFilter,
onFilterSelected = { addonId -> StreamsRepository.selectFilter(addonId) },
onRefresh = onRefresh,
)
ActiveScrapersStatusBlock(

View file

@ -0,0 +1,9 @@
package com.nuvio.app.navigation
import androidx.compose.runtime.staticCompositionLocalOf
/** True when SwiftUI owns the current iOS tab and navigation stack. */
val LocalUseNativeNavigation = staticCompositionLocalOf { false }
/** True for immersive routes that intentionally keep their Compose-owned exit controls. */
val LocalNativeNavigationBarHidden = staticCompositionLocalOf { false }

View file

@ -0,0 +1,79 @@
package com.nuvio.app.navigation
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlin.reflect.KClass
internal class NuvioNavigator(
private val backStack: NavBackStack<NavKey>,
private val onExternalNavigate: ((AppRoute, launchSingleTop: Boolean) -> Unit)? = null,
private val onExternalBack: (() -> Unit)? = null,
private val onExternalReplace: ((AppRoute) -> Unit)? = null,
private val onRouteRemoved: (AppRoute) -> Unit = {},
) {
val currentRoute: AppRoute?
get() = backStack.lastOrNull() as? AppRoute
val routes: List<AppRoute>
get() = backStack.filterIsInstance<AppRoute>()
fun navigate(route: AppRoute, options: NuvioNavigateOptions.() -> Unit = {}) {
val resolvedOptions = NuvioNavigateOptions().apply(options)
if (resolvedOptions.launchSingleTop && currentRoute == route) return
val popUpToRoute = resolvedOptions.popUpToRoute
if (popUpToRoute != null) {
val targetIndex = backStack.indexOfLast { popUpToRoute.isInstance(it) }
if (targetIndex >= 0) {
if (
onExternalReplace != null &&
resolvedOptions.popUpToInclusive &&
targetIndex == backStack.lastIndex
) {
onExternalReplace(route)
return
}
val firstRemovedIndex = if (resolvedOptions.popUpToInclusive) targetIndex else targetIndex + 1
if (firstRemovedIndex <= backStack.lastIndex) {
val removedRoutes = backStack
.subList(firstRemovedIndex, backStack.size)
.filterIsInstance<AppRoute>()
.toList()
backStack.subList(firstRemovedIndex, backStack.size).clear()
removedRoutes.forEach(onRouteRemoved)
}
}
}
if (resolvedOptions.launchSingleTop && currentRoute == route) return
onExternalNavigate?.invoke(route, resolvedOptions.launchSingleTop) ?: backStack.add(route)
}
fun popBackStack(expectedRoute: AppRoute? = null): Boolean {
if (expectedRoute != null && currentRoute != expectedRoute) return false
if (backStack.size > 1) {
(backStack.removeAt(backStack.lastIndex) as? AppRoute)?.let(onRouteRemoved)
return true
}
onExternalBack?.invoke()
return onExternalBack != null
}
}
internal class NuvioNavigateOptions {
var launchSingleTop: Boolean = false
internal var popUpToRoute: KClass<out AppRoute>? = null
internal var popUpToInclusive: Boolean = false
inline fun <reified T : AppRoute> popUpTo(noinline options: NuvioPopUpToOptions.() -> Unit = {}) {
val resolved = NuvioPopUpToOptions().apply(options)
popUpToRoute = T::class
popUpToInclusive = resolved.inclusive
}
}
internal class NuvioPopUpToOptions {
var inclusive: Boolean = false
}

View file

@ -0,0 +1,149 @@
package com.nuvio.app.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
sealed interface AppRoute : NavKey {
val title: String?
get() = null
val subtitle: String?
get() = null
/** Full-screen destinations such as the video player keep native navigation chrome hidden. */
val hidesNavigationBar: Boolean
get() = false
/** Stable enough to apply Navigation 3 launchSingleTop semantics in SwiftUI. */
val navigationIdentity: String
get() = toString()
/** Lets an explicitly cross-tab route select its native SwiftUI stack. */
val preferredTabName: String?
get() = null
}
@Serializable
sealed interface SettingsDestinationRoute : AppRoute {
override val preferredTabName: String
get() = "Settings"
}
@Serializable
data object TabsRoute : AppRoute
@Serializable
data class DetailRoute(
val type: String,
val id: String,
override val title: String? = null,
) : AppRoute
@Serializable
data class PersonDetailRoute(
val personId: Int,
val personName: String,
val personPhoto: String? = null,
val castAvatarTransitionKey: String? = null,
val preferCrew: Boolean = false,
) : AppRoute {
override val title: String
get() = personName
}
@Serializable
data class EntityBrowseRoute(
val entityKind: String,
val entityId: Int,
val entityName: String,
val sourceType: String = "tv",
) : AppRoute {
override val title: String
get() = entityName
}
/** A settings leaf promoted from the former in-screen page state machine. */
@Serializable
data class SettingsPageRoute(
val pageName: String,
override val title: String,
) : SettingsDestinationRoute
@Serializable
data class HomescreenSettingsRoute(override val title: String = "") : SettingsDestinationRoute
@Serializable
data class MetaScreenSettingsRoute(override val title: String = "") : SettingsDestinationRoute
@Serializable
data class ContinueWatchingSettingsRoute(override val title: String = "") : SettingsDestinationRoute
@Serializable
data class DownloadsSettingsRoute(override val title: String = "") : SettingsDestinationRoute
@Serializable
data class DownloadShowRoute(
val showId: String,
override val title: String,
) : AppRoute
@Serializable
data class AddonsSettingsRoute(override val title: String = "") : SettingsDestinationRoute
@Serializable
data class PluginsSettingsRoute(override val title: String = "") : SettingsDestinationRoute
@Serializable
data class AccountSettingsRoute(override val title: String = "") : SettingsDestinationRoute
@Serializable
data class SupportersContributorsSettingsRoute(override val title: String = "") : SettingsDestinationRoute
@Serializable
data class LicensesAttributionsSettingsRoute(override val title: String = "") : SettingsDestinationRoute
@Serializable
data class CollectionsRoute(override val title: String = "") : SettingsDestinationRoute
@Serializable
data class CollectionEditorRoute(
val collectionId: String? = null,
override val title: String = "",
) : AppRoute
@Serializable
data class CollectionEditorPageRoute(
val collectionId: String? = null,
val pageName: String,
override val title: String,
) : AppRoute
@Serializable
data class FolderDetailRoute(
val collectionId: String,
val folderId: String,
override val title: String = "",
) : AppRoute
@Serializable
data class StreamRoute(
val launchId: Long,
override val title: String = "",
) : AppRoute
@Serializable
data class CatalogRoute(
val launchId: Long,
override val title: String = "",
override val subtitle: String? = null,
) : AppRoute
@Serializable
data class PlayerRoute(
val launchId: Long,
override val title: String = "",
) : AppRoute {
override val hidesNavigationBar: Boolean
get() = true
}

View file

@ -1,12 +1,77 @@
package com.nuvio.app
import androidx.compose.ui.uikit.OnFocusBehavior
import androidx.compose.ui.window.ComposeUIViewController
import com.nuvio.app.core.ui.NativeProfileSwitcherController
import com.nuvio.app.navigation.AppRoute
import platform.UIKit.UIColor
import platform.UIKit.UIViewController
private val nuvioBackgroundColor = UIColor(red = 0.051, green = 0.051, blue = 0.051, alpha = 1.0)
fun MainViewController() = ComposeUIViewController {
@Suppress("unused")
fun MainViewController(): UIViewController = nuvioComposeViewController {
App()
}.apply {
}
@Suppress("unused")
fun MainViewController(
initialTabName: String,
useNativeTabBar: Boolean,
useTabletFloatingTabBar: Boolean,
onNavigate: (AppRoute, Boolean) -> Unit,
onGoBack: () -> Unit,
onReplace: (AppRoute) -> Unit,
onActivate: (String) -> Unit,
onAppReady: (Boolean) -> Unit,
onTabTitles: (String, String, String, String) -> Unit,
nativeProfileSwitcherController: NativeProfileSwitcherController,
): UIViewController {
val initialTab = AppScreenTab.fromName(initialTabName)
return nuvioComposeViewController {
App(
initialTab = initialTab,
useNativeNavigation = true,
useNativeTabBar = useNativeTabBar,
useTabletFloatingTabBar = useTabletFloatingTabBar,
ownsAppRuntime = initialTab == AppScreenTab.Home,
bypassAppGate = initialTab != AppScreenTab.Home,
onNavigate = onNavigate,
onGoBack = onGoBack,
onReplace = onReplace,
onActivate = { tab -> onActivate(tab.name) },
onAppReady = onAppReady,
onTabTitles = onTabTitles,
nativeProfileSwitcherController = nativeProfileSwitcherController,
)
}
}
@Suppress("unused")
fun ScreenViewController(
route: AppRoute,
onNavigate: (AppRoute, Boolean) -> Unit,
onGoBack: () -> Unit,
onReplace: (AppRoute) -> Unit,
onActivate: (String) -> Unit,
): UIViewController = nuvioComposeViewController {
App(
initialRoute = route,
useNativeNavigation = true,
ownsAppRuntime = false,
bypassAppGate = true,
onNavigate = onNavigate,
onGoBack = onGoBack,
onReplace = onReplace,
onActivate = { tab -> onActivate(tab.name) },
)
}
private fun nuvioComposeViewController(
content: @androidx.compose.runtime.Composable () -> Unit,
): UIViewController = ComposeUIViewController(
configure = { onFocusBehavior = OnFocusBehavior.DoNothing },
content = content,
).apply {
view.backgroundColor = nuvioBackgroundColor
}

View file

@ -2,11 +2,16 @@ package com.nuvio.app.core.ui
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBars
import androidx.compose.runtime.Composable
import androidx.compose.ui.uikit.LocalUIViewController
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.useContents
internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp
internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp
@ -15,3 +20,14 @@ internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 0.dp
@Composable
internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets =
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
@OptIn(ExperimentalForeignApi::class)
@Composable
internal actual fun platformPhysicalTopInset(): Dp {
val physicalTop = LocalUIViewController.current.view.window
?.safeAreaInsets
?.useContents { top.toFloat() }
return physicalTop?.dp
?: WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
}

View file

@ -5,7 +5,7 @@ android-compileSdkMinor = "0"
android-minSdk = "24"
android-targetSdk = "36"
androidx-activity = "1.12.2"
androidx-navigation = "2.9.2"
androidx-navigation3 = "1.1.1"
androidx-appcompat = "1.7.1"
androidx-core = "1.17.0"
androidx-core-splashscreen = "1.0.1"
@ -44,7 +44,7 @@ androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "an
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
androidx-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "androidx-navigation3" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }

View file

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "nuvio-tab-home.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="#000" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>

After

Width:  |  Height:  |  Size: 153 B

View file

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "nuvio-tab-library.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="#000" d="M8.50989 2.00001H15.49C15.7225 1.99995 15.9007 1.99991 16.0565 2.01515C17.1643 2.12352 18.0711 2.78958 18.4556 3.68678H5.54428C5.92879 2.78958 6.83555 2.12352 7.94337 2.01515C8.09917 1.99991 8.27741 1.99995 8.50989 2.00001Z"/>
<path fill="#000" d="M6.31052 4.72312C4.91989 4.72312 3.77963 5.56287 3.3991 6.67691C3.39117 6.70013 3.38356 6.72348 3.37629 6.74693C3.77444 6.62636 4.18881 6.54759 4.60827 6.49382C5.68865 6.35531 7.05399 6.35538 8.64002 6.35547H15.5321C17.1181 6.35538 18.4835 6.35531 19.5639 6.49382C19.9833 6.54759 20.3977 6.62636 20.7958 6.74693C20.7886 6.72348 20.781 6.70013 20.773 6.67691C20.3925 5.56287 19.2522 4.72312 17.8616 4.72312H6.31052Z"/>
<path fill="#000" fill-rule="evenodd" d="M8.67239 7.54204H15.3276C18.7024 7.54204 20.3898 7.54204 21.3377 8.52887C22.2855 9.5157 22.0625 11.0403 21.6165 14.0896L21.1935 16.9811C20.8437 19.3724 20.6689 20.568 19.7717 21.284C18.8745 22 17.5512 22 14.9046 22H9.09536C6.44881 22 5.12553 22 4.22834 21.284C3.33115 20.568 3.15626 19.3724 2.80648 16.9811L2.38351 14.0896C1.93748 11.0403 1.71447 9.5157 2.66232 8.52887C3.61017 7.54204 5.29758 7.54204 8.67239 7.54204ZM8 18.0001C8 17.5859 8.3731 17.2501 8.83333 17.2501H15.1667C15.6269 17.2501 16 17.5859 16 18.0001C16 18.4144 15.6269 18.7502 15.1667 18.7502H8.83333C8.3731 18.7502 8 18.4144 8 18.0001Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "nuvio-tab-profile.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="#000" d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0 2c-2.67 0-8 1.34-8 4v1c0 .55.45 1 1 1h14c.55 0 1-.45 1-1v-1c0-2.66-5.33-4-8-4Z"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

View file

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "nuvio-tab-search.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path fill="#000" fill-rule="evenodd" d="M4 9a5 5 0 1 1 10 0A5 5 0 0 1 4 9Zm5-7a7 7 0 1 0 4.2 12.6.999.999 0 0 0 .093.107l3 3a1 1 0 0 0 1.414-1.414l-3-3a.999.999 0 0 0-.107-.093A7 7 0 0 0 9 2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

File diff suppressed because it is too large Load diff