diff --git a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt index 503ac7a21..ec33ffa8d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt @@ -1,4 +1,4 @@ -package com.theveloper.pixelplay +package com.theveloper.pixelplay import com.theveloper.pixelplay.presentation.navigation.navigateSafely @@ -46,7 +46,9 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -54,6 +56,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -80,16 +84,20 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalView import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.widthIn import com.theveloper.pixelplay.presentation.viewmodel.PlayerSheetState import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.core.net.toUri @@ -122,6 +130,7 @@ import com.theveloper.pixelplay.presentation.components.DrawerDestination import com.theveloper.pixelplay.presentation.components.MiniPlayerBottomSpacer import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight import com.theveloper.pixelplay.presentation.components.PlayerInternalNavigationBar +import com.theveloper.pixelplay.presentation.components.PlayerInternalNavigationRail import com.theveloper.pixelplay.presentation.components.PlayStoreAnnouncementDefaults import com.theveloper.pixelplay.presentation.components.PlayStoreAnnouncementDialog import com.theveloper.pixelplay.presentation.components.PlayStoreAnnouncementUiModel @@ -608,6 +617,11 @@ class MainActivity : ComponentActivity() { private fun MainUI(playerViewModel: PlayerViewModel, navController: NavHostController) { Trace.beginSection("MainActivity.MainUI") + val configuration = LocalConfiguration.current + val useNavigationRail = remember(configuration) { + configuration.screenWidthDp > 600 + } + val commonNavItems = remember { persistentListOf( BottomNavItem("Home", R.string.nav_bar_home, R.drawable.rounded_home_24, R.drawable.home_24_rounded_filled, Screen.Home), @@ -703,8 +717,14 @@ class MainActivity : ComponentActivity() { ) val bottomBarPadding = animatedBottomBarPadding val navBarHeight = resolveNavBarSurfaceHeight(navBarStyle, systemNavBarInset, navBarCompactMode) - val navBarOccupiedHeight by remember(systemNavBarInset, navBarCompactMode) { - derivedStateOf { resolveNavBarOccupiedHeight(systemNavBarInset, navBarCompactMode) } + val navBarOccupiedHeight by remember(systemNavBarInset, navBarCompactMode, useNavigationRail) { + derivedStateOf { + if (useNavigationRail) { + systemNavBarInset + } else { + resolveNavBarOccupiedHeight(systemNavBarInset, navBarCompactMode) + } + } } val navBarVisibilityProgressState = animateFloatAsState( targetValue = if (shouldHideNavigationBar) 0f else 1f, @@ -798,7 +818,7 @@ class MainActivity : ComponentActivity() { Scaffold( modifier = Modifier.fillMaxSize(), bottomBar = { - if (shouldRenderNavigationBar) { + if (shouldRenderNavigationBar && !useNavigationRail) { val currentSongId by remember { playerViewModel.stablePlayerState .map { it.currentSong?.id } @@ -839,6 +859,7 @@ class MainActivity : ComponentActivity() { Box( modifier = Modifier .fillMaxWidth() + .widthIn(max = 540.dp) .height(navBarOccupiedHeight) .clipToBounds() ) { @@ -850,6 +871,7 @@ class MainActivity : ComponentActivity() { modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() + .widthIn(max = 540.dp) .padding(bottom = bottomBarPadding) .onSizeChanged { componentHeightPx = it.height } .graphicsLayer { @@ -870,7 +892,7 @@ class MainActivity : ComponentActivity() { .padding(horizontal = horizontalPadding) .graphicsLayer { // Animated corner shape resolved in the draw phase: - // animating the radius re-clips this layer only — no + // animating the radius re-clips this layer only 閳?no // recomposition and no layout pass for the bar. val fraction = playerViewModel.playerContentExpansionFraction.value val safeFraction = fraction.coerceIn(0f, 1f) @@ -902,20 +924,56 @@ class MainActivity : ComponentActivity() { compactMode = navBarCompactMode, bottomBarPadding = bottomBarPadding, onSearchIconDoubleTap = onSearchIconDoubleTap, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize().widthIn(max = 540.dp) ) } } } } ) { innerPadding -> - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val density = LocalDensity.current - val containerHeight = this.maxHeight - val screenHeightPx = remember(containerHeight, density) { - with(density) { containerHeight.toPx() } + val appNavigationPadding = remember(innerPadding, useNavigationRail, shouldRenderNavigationBar) { + if (useNavigationRail && shouldRenderNavigationBar) { + androidx.compose.foundation.layout.PaddingValues( + start = innerPadding.calculateStartPadding(LayoutDirection.Ltr) + 80.dp, + top = innerPadding.calculateTopPadding(), + end = innerPadding.calculateEndPadding(LayoutDirection.Ltr), + bottom = innerPadding.calculateBottomPadding() + ) + } else { + innerPadding } + } + Row(modifier = Modifier.fillMaxSize()) { + if (useNavigationRail && shouldRenderNavigationBar) { + PlayerInternalNavigationRail( + navController = navController, + navItems = commonNavItems, + currentRoute = currentRoute, + onSearchIconDoubleTap = { playerViewModel.onSearchNavIconDoubleTapped() }, + onOpenSidebar = { scope.launch { drawerState.open() } }, + modifier = Modifier + .layout { measurable, constraints -> + val expansionHide = playerViewModel.playerContentExpansionFraction.value.coerceIn(0f, 1f) + val routeHide = (1f - navBarVisibilityProgressState.value).coerceIn(0f, 1f) + val hideFraction = maxOf(expansionHide, routeHide) + val placeable = measurable.measure(constraints) + val shrinkBy = (placeable.width * hideFraction).roundToInt() + layout(placeable.width - shrinkBy, placeable.height) { + placeable.placeRelative(0, 0) + } + } + .graphicsLayer { + // reading state here is fine: graphicsLayer runs per frame, not per layout + val expansionHide = playerViewModel.playerContentExpansionFraction.value.coerceIn(0f, 1f) + val routeHide = (1f - navBarVisibilityProgressState.value).coerceIn(0f, 1f) + val hideFraction = maxOf(expansionHide, routeHide) + // size.width is already reduced by the layout modifier above + translationX = -size.width * hideFraction + alpha = 1f - hideFraction + } + ) + } val showPlayerContentInitially by remember { playerViewModel.stablePlayerState .map { it.currentSong?.id != null } @@ -925,7 +983,30 @@ class MainActivity : ComponentActivity() { val shouldHideMiniPlayer by remember(currentRoute) { derivedStateOf { currentRoute in routesWithHiddenMiniPlayer } } - + val density = LocalDensity.current + val collapsedMaxWidthDp by remember { + derivedStateOf { + val expansionHide = playerViewModel.playerContentExpansionFraction.value.coerceIn(0f, 1f) + val routeHide = (1f - navBarVisibilityProgressState.value).coerceIn(0f, 1f) + val hideFraction = maxOf(expansionHide, routeHide) + lerp(450.dp, 2000.dp, hideFraction) + } + } + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .then( + if (useNavigationRail) { + Modifier.widthIn(max = if (showPlayerContentInitially && !shouldHideMiniPlayer) collapsedMaxWidthDp else Dp.Infinity) + } else { + Modifier.widthIn(max = 540.dp) + } + ) + ) { + val containerHeight = this.maxHeight + val screenHeightPx = remember(containerHeight, density) { + with(density) { containerHeight.toPx() } + } val miniPlayerH = with(density) { MiniPlayerHeight.toPx() } val totalSheetHeightWhenContentCollapsedPx = if (showPlayerContentInitially && !shouldHideMiniPlayer) miniPlayerH else 0f @@ -967,7 +1048,7 @@ class MainActivity : ComponentActivity() { AppNavigation( playerViewModel = playerViewModel, navController = navController, - paddingValues = innerPadding, + paddingValues = appNavigationPadding, userPreferencesRepository = userPreferencesRepository, onSearchBarActiveChange = { isSearchBarActive = it }, onOpenSidebar = { scope.launch { drawerState.open() } } @@ -979,7 +1060,7 @@ class MainActivity : ComponentActivity() { playerViewModel.playerContentExpansionFraction.value > 0.01f } } - AnimatedVisibility( + androidx.compose.animation.AnimatedVisibility( visible = isExpandedOrExpanding, enter = fadeIn(animationSpec = tween(durationMillis = 350)), exit = fadeOut(animationSpec = tween(durationMillis = 350)), @@ -1008,7 +1089,8 @@ class MainActivity : ComponentActivity() { hideMiniPlayer = shouldHideMiniPlayer, containerHeight = containerHeight, navController = navController, - isNavBarHidden = isNavBarEffectivelyHidden + isNavBarHidden = isNavBarEffectivelyHidden, + isNavRailHidden = useNavigationRail ) val dismissUndoBarSlice by remember { @@ -1028,7 +1110,7 @@ class MainActivity : ComponentActivity() { { playerViewModel.hideDismissUndoBar() } } - AnimatedVisibility( + androidx.compose.animation.AnimatedVisibility( visible = dismissUndoBarSlice.isVisible, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), @@ -1061,6 +1143,7 @@ class MainActivity : ComponentActivity() { } } } + } } Trace.endSection() diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/BackupModuleSelectionDialog.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/BackupModuleSelectionDialog.kt index 1584be7e5..8b7b7af7d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/BackupModuleSelectionDialog.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/BackupModuleSelectionDialog.kt @@ -76,6 +76,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.widthIn import androidx.compose.ui.res.stringResource @OptIn( @@ -135,7 +136,7 @@ fun BackupModuleSelectionDialog( label = "import_module_selection_dialog" ) { Surface( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().widthIn(max = 540.dp), color = MaterialTheme.colorScheme.surfaceContainerLowest ) { Scaffold( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/BetaInfoBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/BetaInfoBottomSheet.kt index 0370ac54d..8e46e4a5a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/BetaInfoBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/BetaInfoBottomSheet.kt @@ -127,7 +127,7 @@ fun BetaInfoBottomSheet(modifier: Modifier = Modifier) { alpha = 0.95f, strokeWidth = 4.dp, amplitude = 4.dp, - waves = 7.6f, + wavesDensity = 7.6f, phase = 0f ) } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt index 5d8a3860d..1ef3a16e9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt @@ -165,7 +165,7 @@ fun ChangelogBottomSheet( alpha = 0.95f, strokeWidth = 4.dp, amplitude = 4.dp, - waves = 7.6f, + wavesDensity = 7.6f, phase = 0f ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/FileExplorerBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/FileExplorerBottomSheet.kt index e1aed1b4c..43d85a34d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/FileExplorerBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/FileExplorerBottomSheet.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -141,7 +142,7 @@ fun FileExplorerDialog( label = "file_explorer_dialog" ) { Surface( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().widthIn(max = 540.dp), color = MaterialTheme.colorScheme.surfaceContainerLow ) { FileExplorerContent( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlayerInternalNavigationBar.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlayerInternalNavigationBar.kt index 651c5f26e..86b1a3dbe 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlayerInternalNavigationBar.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlayerInternalNavigationBar.kt @@ -3,17 +3,46 @@ package com.theveloper.pixelplay.presentation.components import com.theveloper.pixelplay.presentation.navigation.navigateToTopLevelSafely import android.os.SystemClock +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -22,12 +51,21 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.navigation.NavHostController import com.theveloper.pixelplay.BottomNavItem +import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.preferences.NavBarStyle import com.theveloper.pixelplay.presentation.components.scoped.CustomNavigationBarItem import com.theveloper.pixelplay.presentation.navigation.Screen @@ -245,3 +283,271 @@ fun PlayerInternalNavigationBar( modifier = modifier ) } + +@Composable +fun ColumnScope.CustomNavigationRailItem( + selected: Boolean, + onClick: () -> Unit, + icon: @Composable () -> Unit, + selectedIcon: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + label: @Composable (() -> Unit)? = null, + contentDescription: String? = null, + alwaysShowLabel: Boolean = true, + selectedIconColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSecondaryContainer, + unselectedIconColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurfaceVariant, + selectedTextColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, + unselectedTextColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurfaceVariant, + indicatorColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.secondaryContainer, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + val iconColor by animateColorAsState( + targetValue = if (selected) selectedIconColor else unselectedIconColor, + animationSpec = tween(durationMillis = 150), + label = "iconColor" + ) + + val textColor by animateColorAsState( + targetValue = if (selected) selectedTextColor else unselectedTextColor, + animationSpec = tween(durationMillis = 150), + label = "textColor" + ) + + val iconScale by animateFloatAsState( + targetValue = if (selected) 1.1f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "iconScale" + ) + + val showLabel = label != null && (alwaysShowLabel || selected) + val indicatorWidth = 64.dp + val indicatorHeight = 32.dp + val iconWidth = 48.dp + val iconHeight = 24.dp + val indicatorPadding = 4.dp + val indicatorShape = RoundedCornerShape(16.dp) + val iconShape = RoundedCornerShape(12.dp) + + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .clickable( + onClick = onClick, + enabled = enabled, + role = Role.Tab, + interactionSource = interactionSource, + indication = null + ) + .semantics { + if (contentDescription != null) { + this.contentDescription = contentDescription + } + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(indicatorWidth, indicatorHeight) + ) { + androidx.compose.animation.AnimatedVisibility( + visible = selected, + enter = fadeIn(animationSpec = tween(100)) + scaleIn(animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)), + exit = fadeOut(animationSpec = tween(100)) + scaleOut(animationSpec = tween(100, easing = CubicBezierEasing(0.5f, 0f, 0.75f, 0f))) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = indicatorPadding) + .background( + color = indicatorColor, + shape = indicatorShape + ) + ) + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(iconWidth, iconHeight) + .clip(iconShape) + .graphicsLayer { + scaleX = iconScale + scaleY = iconScale + } + ) { + CompositionLocalProvider(LocalContentColor provides iconColor) { + Box( + modifier = Modifier.clearAndSetSemantics { + if (showLabel) { } + } + ) { + if (selected) selectedIcon() else icon() + } + } + } + } + + androidx.compose.animation.AnimatedVisibility( + visible = showLabel, + enter = fadeIn(animationSpec = tween(200, delayMillis = 50)), + exit = fadeOut(animationSpec = tween(100)) + ) { + Spacer(modifier = Modifier.height(4.dp)) + Box( + modifier = Modifier.padding(top = 4.dp) + ) { + ProvideTextStyle( + value = MaterialTheme.typography.labelMedium.copy( + color = textColor, + fontSize = 13.sp, + fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal + ) + ) { + label?.invoke() + } + } + } + } +} + +@Composable +fun PlayerInternalNavigationRail( + navController: NavHostController, + navItems: ImmutableList, + currentRoute: String?, + modifier: Modifier = Modifier, + onSearchIconDoubleTap: () -> Unit = {}, + onOpenSidebar: () -> Unit = {} +) { + val latestCurrentRoute by rememberUpdatedState(currentRoute) + val latestOnSearchIconDoubleTap by rememberUpdatedState(onSearchIconDoubleTap) + val latestNavigationEnabled by rememberUpdatedState(currentRoute != null) + + Surface( + modifier = modifier + .fillMaxHeight() + .width(80.dp), + color = NavigationBarDefaults.containerColor, + tonalElevation = 3.dp + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + IconButton( + onClick = onOpenSidebar, + modifier = Modifier.padding(bottom = 16.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_menu_24), + contentDescription = "Open Drawer", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + val scope = rememberCoroutineScope() + var lastSearchTapTimestamp by remember { mutableStateOf(0L) } + + navItems.forEach { item -> + val isSelected = currentRoute != null && currentRoute == item.screen.route + val selectedColor = MaterialTheme.colorScheme.primary + val unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant + val indicatorColorFromTheme = MaterialTheme.colorScheme.secondaryContainer + + val iconPainterResId = if (isSelected && item.selectedIconResId != null && item.selectedIconResId != 0) { + item.selectedIconResId + } else { + item.iconResId + } + val localizedLabel = stringResource(id = item.labelResId) + val iconLambda: @Composable () -> Unit = remember(iconPainterResId, localizedLabel) { + { + Icon( + painter = painterResource(id = iconPainterResId), + contentDescription = localizedLabel + ) + } + } + val selectedIconLambda: @Composable () -> Unit = remember(iconPainterResId, localizedLabel) { + { + Icon( + painter = painterResource(id = iconPainterResId), + contentDescription = localizedLabel + ) + } + } + val labelLambda: @Composable () -> Unit = remember(localizedLabel) { + { Text(localizedLabel) } + } + + val onClickLambda: () -> Unit = remember(item.screen.route, navController, scope) { + click@{ + if (!latestNavigationEnabled) { + lastSearchTapTimestamp = 0L + return@click + } + + val itemRoute = item.screen.route + val isSearchTab = itemRoute == Screen.Search.route + val isAlreadySelected = latestCurrentRoute == itemRoute + + if (isSearchTab) { + val now = SystemClock.elapsedRealtime() + val isDoubleTap = now - lastSearchTapTimestamp <= 350L + lastSearchTapTimestamp = now + + if (!isAlreadySelected) { + if (!navController.navigateToTopLevelSafely(itemRoute)) { + lastSearchTapTimestamp = 0L + return@click + } + } + + if (isDoubleTap) { + lastSearchTapTimestamp = 0L + if (isAlreadySelected) { + latestOnSearchIconDoubleTap() + } else { + scope.launch { + delay(160L) + latestOnSearchIconDoubleTap() + } + } + } + } else if (!isAlreadySelected) { + lastSearchTapTimestamp = 0L + navController.navigateToTopLevelSafely(itemRoute) + } else { + lastSearchTapTimestamp = 0L + } + } + } + + CustomNavigationRailItem( + selected = isSelected, + onClick = onClickLambda, + enabled = currentRoute != null, + icon = iconLambda, + selectedIcon = selectedIconLambda, + label = labelLambda, + contentDescription = localizedLabel, + alwaysShowLabel = true, + selectedIconColor = selectedColor, + unselectedIconColor = unselectedColor, + selectedTextColor = selectedColor, + unselectedTextColor = unselectedColor, + indicatorColor = indicatorColorFromTheme + ) + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt index ca39bb451..cff5d286c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -577,6 +578,7 @@ fun CreatePlaylistDialogRedesigned( ) { Column( modifier = Modifier + .widthIn(max = 540.dp) .padding(24.dp) .fillMaxWidth() ) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt index 2aed35222..5f6915fc4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable @@ -69,7 +70,8 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( onQueueDragStart: () -> Unit, onQueueDrag: (Float) -> Unit, onQueueRelease: (Float, Float) -> Unit, - onShowCastClicked: () -> Unit + onShowCastClicked: () -> Unit, + isNavRailHidden: Boolean ) { currentSong?.let { currentSongNonNull -> miniPlayerScheme?.let { readyScheme -> @@ -116,7 +118,7 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( .zIndex(miniPlayerZIndex) ) { val isMiniPlayerVisible by remember { - derivedStateOf { playerContentExpansionFraction.value < 0.01f } + derivedStateOf { playerContentExpansionFraction.value < 0.000001f } //0.01f is really huge for it } MiniPlayerContentInternal( song = currentSongNonNull, @@ -127,7 +129,9 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( onPrevious = { playerViewModel.previousSong() }, onNext = { playerViewModel.nextSong() }, canScroll = isMiniPlayerVisible && infrequentPlayerState.isPlaying, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize().then( + if (isNavRailHidden && !isMiniPlayerVisible) Modifier.padding(end = 80.dp) else Modifier + ) ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt index 59ae7c316..71f733653 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Pause @@ -83,6 +84,7 @@ internal fun MiniPlayerContentInternal( Row( modifier = modifier .fillMaxWidth() + .widthIn(max = 450.dp) .height(MiniPlayerHeight) .padding(start = 10.dp, end = 12.dp), verticalAlignment = Alignment.CenterVertically diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt index d692ed9fd..21718a40d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt @@ -1,4 +1,4 @@ -package com.theveloper.pixelplay.presentation.components +package com.theveloper.pixelplay.presentation.components import android.widget.Toast import com.theveloper.pixelplay.presentation.components.ExpressiveOfflineDialog @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.ui.layout.layout import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.MotionScheme @@ -115,7 +116,8 @@ fun UnifiedPlayerSheetV2( collapsedStateHorizontalPadding: Dp = 12.dp, navController: NavHostController, hideMiniPlayer: Boolean = false, - isNavBarHidden: Boolean = false + isNavBarHidden: Boolean = false, + isNavRailHidden: Boolean = false ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current @@ -620,6 +622,7 @@ fun UnifiedPlayerSheetV2( Surface( modifier = Modifier .fillMaxWidth() + .widthIn(max = 450.dp) .layout { measurable, constraints -> val translationY = visualSheetTranslationYProvider().roundToInt() val overshoot = if (currentSheetContentState == PlayerSheetState.EXPANDED && !isDragging) { @@ -772,7 +775,8 @@ fun UnifiedPlayerSheetV2( onQueueDragStart = sheetActionHandlers.beginQueueDrag, onQueueDrag = sheetActionHandlers.dragQueueBy, onQueueRelease = sheetActionHandlers.endQueueDrag, - onShowCastClicked = castSheetState.openCastSheet + onShowCastClicked = castSheetState.openCastSheet, + isNavRailHidden = isNavRailHidden, ) } } @@ -815,6 +819,7 @@ fun UnifiedPlayerSheetV2( ) queuePredictiveBackSwipeEdge = null } + } } catch (_: kotlin.coroutines.cancellation.CancellationException) { scope.launch { @@ -832,6 +837,7 @@ fun UnifiedPlayerSheetV2( } } + val queuePredictiveBackSwipeEdgeState = rememberUpdatedState(queuePredictiveBackSwipeEdge) UnifiedPlayerQueueAndSongInfoHost( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/PlayerSheetPredictiveBackHandler.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/PlayerSheetPredictiveBackHandler.kt index fd533e570..f763b7174 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/PlayerSheetPredictiveBackHandler.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/PlayerSheetPredictiveBackHandler.kt @@ -1,4 +1,4 @@ -package com.theveloper.pixelplay.presentation.components.scoped +package com.theveloper.pixelplay.presentation.components.scoped import android.os.Build import androidx.activity.compose.BackHandler @@ -34,9 +34,12 @@ internal fun PlayerSheetPredictiveBackHandler( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { PredictiveBackHandler(enabled = enabled) { progressFlow -> try { + val startingExpansionFraction = playerViewModel.playerContentExpansionFraction.value progressFlow.collect { backEvent -> onSwipeEdgeChanged(backEvent.swipeEdge) playerViewModel.updatePredictiveBackCollapseFraction(backEvent.progress) + val contractedFraction = ((1f - backEvent.progress) * startingExpansionFraction).coerceIn(0f, 1f) + playerViewModel.playerContentExpansionFraction.snapTo(contractedFraction) } scope.launch { val progressAtRelease = playerViewModel.predictiveBackCollapseFraction.value @@ -48,6 +51,7 @@ internal fun PlayerSheetPredictiveBackHandler( ) playerViewModel.updatePredictiveBackCollapseFraction(1f) playerViewModel.collapsePlayerSheet() + playerViewModel.playerContentExpansionFraction.snapTo(0f) playerViewModel.updatePredictiveBackCollapseFraction(0f) onSwipeEdgeChanged(null) } @@ -62,8 +66,13 @@ internal fun PlayerSheetPredictiveBackHandler( if (playerViewModel.sheetState.value == PlayerSheetState.EXPANDED) { playerViewModel.expandPlayerSheet() + playerViewModel.playerContentExpansionFraction.animateTo( + targetValue = 1f, + animationSpec = tween(animationDurationMs) + ) } else { playerViewModel.collapsePlayerSheet() + playerViewModel.playerContentExpansionFraction.snapTo(0f) } onSwipeEdgeChanged(null) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/SineWaveLine.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/SineWaveLine.kt index ea703dad3..55e24441c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/SineWaveLine.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/SineWaveLine.kt @@ -22,7 +22,7 @@ import kotlin.math.sin * @param alpha Opacidad (0f..1f). * @param strokeWidth Grosor de la línea (Dp). * @param amplitude Amplitud de la onda (Dp) — la altura máxima desde el centro. - * @param waves Número de ondas completas a lo largo del ancho (ej: 1f = una onda). + * @param wavesDensity Density of wave (float) - as the number in standard screen width 380dp * @param phase Desplazamiento de fase estático (radianes). Se usa solo si animate = false. * @param animate Si es true, activa una animación de desplazamiento infinita. * @param animationDurationMillis Duración en milisegundos de un ciclo completo de animación. @@ -36,7 +36,7 @@ fun SineWaveLine( alpha: Float = 1f, strokeWidth: Dp = 2.dp, amplitude: Dp = 8.dp, - waves: Float = 2f, + wavesDensity: Float = 7.6f, phase: Float = 0f, animate: Boolean? = false, animationDurationMillis: Int = 2000, @@ -80,8 +80,8 @@ fun SineWaveLine( moveTo(0f, centerY + (ampPx * sin(currentPhase))) for (i in 1 until samples) { val x = i * step - // theta recorre 0..(2π * waves) - val theta = (x / w) * (2f * PI.toFloat() * waves) + currentPhase + // theta recorre 0..(2π * wavesDensity) + val theta = (x / w) * (2f * PI.toFloat() * (wavesDensity) * size.width / 380.dp.toPx()) + currentPhase val y = centerY + ampPx * sin(theta) lineTo(x, y) } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt index ba622628f..76610dff8 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -49,6 +50,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -111,6 +113,7 @@ import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import com.theveloper.pixelplay.presentation.viewmodel.SettingsViewModel import com.theveloper.pixelplay.presentation.viewmodel.StatsViewModel import com.theveloper.pixelplay.ui.theme.ExpTitleTypography +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay @@ -121,6 +124,12 @@ import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import androidx.compose.ui.res.stringResource private const val HomeLoadingPlaceholderMinDurationMillis = 1200L +private val HomeTabletBreakpoint = 600.dp + +private data class HomeTabletModule( + val key: String, + val content: @Composable () -> Unit +) // Modern HomeScreen with collapsible top bar and staggered grid layout @androidx.annotation.OptIn(UnstableApi::class) @@ -305,6 +314,111 @@ fun HomeScreen( val shouldShowCleanInstallDisclaimer = settingsUiState.beta05CleanInstallDisclaimerDismissed == false && !cleanInstallDisclaimerDismissedThisSession + val yourMixModule: @Composable () -> Unit = { + HomeYourMixModule( + yourMixSongs = yourMixSongs, + song = yourMixSong, + isShuffleEnabled = isShuffleEnabled, + shouldShowYourMixLoadingPlaceholder = shouldShowYourMixLoadingPlaceholder, + onRefresh = { + homePlaceholderRefreshGeneration++ + settingsViewModel.refreshLibrary() + playerViewModel.forceUpdateDailyMix() + }, + onPlayShuffled = { + if (usesFallbackHomeMix) { + playerViewModel.shuffleAllSongs(queueName = "Your Mix") + } else { + playerViewModel.playSongsShuffled( + songsToPlay = yourMixSongs, + queueName = "Your Mix", + startAtZero = true, + ) + } + } + ) + } + val albumArtCollageModule: @Composable () -> Unit = { + HomeAlbumArtCollageModule( + songs = yourMixSongs, + basePattern = settingsUiState.collagePattern, + isAutoRotate = settingsUiState.collageAutoRotate, + onSongClick = { song -> + if (usesFallbackHomeMix) { + playerViewModel.showAndPlaySongFromLibrary(song, queueName = "Your Mix") + } else { + playerViewModel.showAndPlaySong(song, yourMixSongs, "Your Mix") + } + } + ) + } + val dailyMixModule: @Composable () -> Unit = { + DailyMixSection( + songs = dailyMixSongs, + onClickOpen = { + navController.navigateSafely(Screen.DailyMixScreen.route) + }, + onNavigateToAlbum = { song -> + navController.navigateSafelyReplacing( + route = Screen.AlbumDetail.createRoute(song.albumId), + patternToPop = Screen.AlbumDetail.route + ) + }, + onNavigateToArtist = { song -> + navController.navigateSafelyReplacing( + route = Screen.ArtistDetail.createRoute(song.artistId), + patternToPop = Screen.ArtistDetail.route + ) + }, + onNavigateToGenre = { song -> + song.genre?.let { + navController.navigateSafely(Screen.GenreDetail.createRoute(java.net.URLEncoder.encode(it, "UTF-8"))) + } + }, + playerViewModel = playerViewModel + ) + } + val recentlyPlayedModule: @Composable () -> Unit = { + RecentlyPlayedSection( + songs = recentlyPlayedSongs, + onSongClick = { song -> + if (recentlyPlayedQueue.isNotEmpty()) { + playerViewModel.playSongs( + songsToPlay = recentlyPlayedQueue, + startSong = song, + queueName = "Recently Played" + ) + } + }, + onOpenAllClick = { + navController.navigateSafely(Screen.RecentlyPlayed.route) + }, + themeStateHolder = playerViewModel.themeStateHolder, + currentSongId = currentSong?.id, + contentPadding = PaddingValues(start = 8.dp, end = 24.dp) + ) + } + val statsModule: @Composable () -> Unit = { + StatsOverviewCard( + summary = homeStatsOverview, + onClick = { navController.navigateSafely(Screen.Stats.route) } + ) + } + val tabletModules = buildList { + add(HomeTabletModule(key = "tablet_your_mix", content = yourMixModule)) + if (yourMixSongs.isNotEmpty()) { + add(HomeTabletModule(key = "tablet_album_art_collage", content = albumArtCollageModule)) + } + if (dailyMixSongs.isNotEmpty()) { + add(HomeTabletModule(key = "tablet_daily_mix", content = dailyMixModule)) + } + if (recentlyPlayedSongs.size >= RecentlyPlayedSectionMinSongsToShow) { + add(HomeTabletModule(key = "tablet_recently_played", content = recentlyPlayedModule)) + } + if (homeStatsOverview != null) { + add(HomeTabletModule(key = "tablet_listening_stats", content = statsModule)) + } + } Box( modifier = Modifier.fillMaxSize() @@ -332,163 +446,76 @@ fun HomeScreen( ) } ) { innerPadding -> - LazyColumn( - state = listState, + BoxWithConstraints( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - contentPadding = PaddingValues( - top = innerPadding.calculateTopPadding(), - bottom = paddingValuesParent.calculateBottomPadding() - + 38.dp + bottomPadding - ), - verticalArrangement = Arrangement.spacedBy(24.dp) + .background(MaterialTheme.colorScheme.background) ) { - if (yourMixSongs.isEmpty()) { - item( - key = "your_mix_placeholder", - contentType = "your_mix_placeholder" - ) { - if (shouldShowYourMixLoadingPlaceholder) { - YourMixLoadingPlaceholder() - } else { - YourMixEmptyPlaceholder( - onRefresh = { - homePlaceholderRefreshGeneration++ - settingsViewModel.refreshLibrary() - playerViewModel.forceUpdateDailyMix() - } - ) + val isTabletLayout = maxWidth >= HomeTabletBreakpoint + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + top = innerPadding.calculateTopPadding(), + bottom = paddingValuesParent.calculateBottomPadding() + + 38.dp + bottomPadding + ), + verticalArrangement = Arrangement.spacedBy(if (isTabletLayout) 20.dp else 24.dp) + ) { + if (isTabletLayout) { + item( + key = tabletModules.joinToString( + separator = "_", + prefix = "tablet_columns_" + ) { it.key }, + contentType = "tablet_module_columns" + ) { + HomeTabletModuleColumns(modules = tabletModules) + } + } else { + item( + key = if (yourMixSongs.isEmpty()) "your_mix_placeholder" else "your_mix_header", + contentType = if (yourMixSongs.isEmpty()) "your_mix_placeholder" else "your_mix_header" + ) { + yourMixModule() } - } - } else { - item( - key = "your_mix_header", - contentType = "your_mix_header" - ) { - YourMixHeader( - song = yourMixSong, - isShuffleEnabled = isShuffleEnabled, - onPlayShuffled = { - if (usesFallbackHomeMix) { - playerViewModel.shuffleAllSongs(queueName = "Your Mix") - } else { - playerViewModel.playSongsShuffled( - songsToPlay = yourMixSongs, - queueName = "Your Mix", - startAtZero = true, - ) - } - } - ) - } - } - // Collage - if (yourMixSongs.isNotEmpty()) { - item( - key = "album_art_collage", - contentType = "album_art_collage" - ) { - val basePattern = settingsUiState.collagePattern - val isAutoRotate = settingsUiState.collageAutoRotate - val patterns = remember { CollagePattern.entries } - - val activePattern = if (isAutoRotate) { - var rotationIndex by rememberSaveable { mutableIntStateOf(-1) } - LaunchedEffect(Unit) { rotationIndex++ } - remember(rotationIndex) { - patterns[rotationIndex.coerceAtLeast(0) % patterns.size] + if (yourMixSongs.isNotEmpty()) { + item( + key = "album_art_collage", + contentType = "album_art_collage" + ) { + albumArtCollageModule() } - } else { - basePattern } - AlbumArtCollage( - modifier = Modifier.fillMaxWidth(), - songs = yourMixSongs, - padding = 14.dp, - height = 400.dp, - pattern = activePattern, - onSongClick = { song -> - if (usesFallbackHomeMix) { - playerViewModel.showAndPlaySongFromLibrary(song, queueName = "Your Mix") - } else { - playerViewModel.showAndPlaySong(song, yourMixSongs, "Your Mix") - } + if (dailyMixSongs.isNotEmpty()) { + item( + key = "daily_mix_section", + contentType = "daily_mix_section" + ) { + dailyMixModule() } - ) - } - } - - // Daily Mix - if (dailyMixSongs.isNotEmpty()) { - item( - key = "daily_mix_section", - contentType = "daily_mix_section" - ) { - DailyMixSection( - songs = dailyMixSongs, - onClickOpen = { - navController.navigateSafely(Screen.DailyMixScreen.route) - }, - onNavigateToAlbum = { song -> - navController.navigateSafelyReplacing( - route = Screen.AlbumDetail.createRoute(song.albumId), - patternToPop = Screen.AlbumDetail.route - ) - }, - onNavigateToArtist = { song -> - navController.navigateSafelyReplacing( - route = Screen.ArtistDetail.createRoute(song.artistId), - patternToPop = Screen.ArtistDetail.route - ) - }, - onNavigateToGenre = { song -> - song.genre?.let { - navController.navigateSafely(Screen.GenreDetail.createRoute(java.net.URLEncoder.encode(it, "UTF-8"))) - } - }, - playerViewModel = playerViewModel - ) - } - } + } - if (recentlyPlayedSongs.size >= RecentlyPlayedSectionMinSongsToShow) { - item( - key = "recently_played_section", - contentType = "recently_played_section" - ) { - RecentlyPlayedSection( - songs = recentlyPlayedSongs, - onSongClick = { song -> - if (recentlyPlayedQueue.isNotEmpty()) { - playerViewModel.playSongs( - songsToPlay = recentlyPlayedQueue, - startSong = song, - queueName = "Recently Played" - ) - } - }, - onOpenAllClick = { - navController.navigateSafely(Screen.RecentlyPlayed.route) - }, - themeStateHolder = playerViewModel.themeStateHolder, - currentSongId = currentSong?.id, - contentPadding = PaddingValues(start = 8.dp, end = 24.dp) - ) - } - } + if (recentlyPlayedSongs.size >= RecentlyPlayedSectionMinSongsToShow) { + item( + key = "recently_played_section", + contentType = "recently_played_section" + ) { + recentlyPlayedModule() + } + } - if (homeStatsOverview != null) { - item( - key = "listening_stats_preview", - contentType = "listening_stats_preview" - ) { - StatsOverviewCard( - summary = homeStatsOverview, - onClick = { navController.navigateSafely(Screen.Stats.route) } - ) + if (homeStatsOverview != null) { + item( + key = "listening_stats_preview", + contentType = "listening_stats_preview" + ) { + statsModule() + } + } } } } @@ -585,6 +612,100 @@ fun HomeScreen( } } +@Composable +private fun HomeTabletModuleColumns( + modules: List +) { + val leftColumnModules = modules.filterIndexed { index, _ -> index % 2 == 0 } + val rightColumnModules = modules.filterIndexed { index, _ -> index % 2 == 1 } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top + ) { + HomeTabletModuleColumn( + modules = leftColumnModules, + modifier = Modifier.weight(1f) + ) + HomeTabletModuleColumn( + modules = rightColumnModules, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun HomeTabletModuleColumn( + modules: List, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + modules.forEach { module -> + key(module.key) { + module.content() + } + } + } +} + +@Composable +private fun HomeYourMixModule( + yourMixSongs: ImmutableList, + song: String, + isShuffleEnabled: Boolean, + shouldShowYourMixLoadingPlaceholder: Boolean, + onRefresh: () -> Unit, + onPlayShuffled: () -> Unit +) { + if (yourMixSongs.isEmpty()) { + if (shouldShowYourMixLoadingPlaceholder) { + YourMixLoadingPlaceholder() + } else { + YourMixEmptyPlaceholder(onRefresh = onRefresh) + } + } else { + YourMixHeader( + song = song, + isShuffleEnabled = isShuffleEnabled, + onPlayShuffled = onPlayShuffled + ) + } +} + +@Composable +private fun HomeAlbumArtCollageModule( + songs: ImmutableList, + basePattern: CollagePattern, + isAutoRotate: Boolean, + onSongClick: (Song) -> Unit +) { + val patterns = remember { CollagePattern.entries } + val activePattern = if (isAutoRotate) { + var rotationIndex by rememberSaveable { mutableIntStateOf(-1) } + LaunchedEffect(Unit) { rotationIndex++ } + remember(rotationIndex) { + patterns[rotationIndex.coerceAtLeast(0) % patterns.size] + } + } else { + basePattern + } + + AlbumArtCollage( + modifier = Modifier.fillMaxWidth(), + songs = songs, + padding = 14.dp, + height = 400.dp, + pattern = activePattern, + onSongClick = onSongClick + ) +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun YourMixLoadingPlaceholder() { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SetupScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SetupScreen.kt index 510010631..44f93c9b7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SetupScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SetupScreen.kt @@ -56,8 +56,10 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState @@ -643,7 +645,7 @@ private fun isIgnoringBatteryOptimizationsNow(context: Context): Boolean { @Composable fun WelcomePage() { Column( - horizontalAlignment = Alignment.CenterHorizontally, + //horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxSize() @@ -672,7 +674,7 @@ fun WelcomePage() { ), ) } - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(4.dp)) Surface( shape = CircleShape, color = MaterialTheme.colorScheme.surface, @@ -707,7 +709,7 @@ fun WelcomePage() { .clip(RoundedCornerShape(20.dp)) ){ MaterialYouVectorDrawable( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.requiredWidth(380.dp).align(Alignment.CenterEnd), drawableResId = R.drawable.welcome_art ) SineWaveLine( @@ -722,7 +724,7 @@ fun WelcomePage() { alpha = 0.95f, strokeWidth = 16.dp, amplitude = 4.dp, - waves = 7.6f, + wavesDensity = 7.6f, phase = 0f ) Box( @@ -748,7 +750,7 @@ fun WelcomePage() { alpha = 0.95f, strokeWidth = 4.dp, amplitude = 4.dp, - waves = 7.6f, + wavesDensity = 7.6f, phase = 0f ) } @@ -1005,6 +1007,7 @@ fun ThemeSelectionPage( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween, modifier = Modifier + .widthIn(max = 540.dp) .fillMaxSize() .padding(24.dp) ) { @@ -1188,6 +1191,7 @@ fun LibraryLayoutPage( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween, modifier = Modifier + .widthIn(max = 540.dp) .fillMaxSize() .padding(24.dp) ) { @@ -1527,6 +1531,7 @@ fun FinishPage() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = Modifier + .widthIn(max = 540.dp) .fillMaxSize() .padding(16.dp) ) { @@ -1556,6 +1561,7 @@ fun PermissionPageLayout( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween, modifier = Modifier + .widthIn(max = 540.dp) .fillMaxSize() .padding(24.dp) ) { @@ -1654,7 +1660,7 @@ private fun SetupRestoreDialog( ) ) { Surface( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().widthIn(max = 540.dp), color = MaterialTheme.colorScheme.surfaceContainerLowest ) { Scaffold( @@ -1924,6 +1930,7 @@ fun LibraryNavigationPillSetupShow( // IntrinsicSize.Min en el Row + fillMaxHeight en los hijos asegura misma altura Row( modifier = Modifier + .widthIn(max = 540.dp) .padding(start = 4.dp) .height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically, @@ -2219,6 +2226,7 @@ fun NavBarLayoutPage( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween, modifier = Modifier + .widthIn(max = 540.dp) .fillMaxSize() .padding(24.dp) ) { @@ -2348,6 +2356,7 @@ fun NavBarPreview(isDefault: Boolean) { containerColor = MaterialTheme.colorScheme.surfaceBright ), modifier = Modifier + .widthIn(max = 540.dp) .fillMaxWidth() .height(200.dp) // Taller to show bottom part clearly .padding(horizontal = 8.dp)