diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 00000000..178a3a40 --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,34 @@ +name: Build Android APK + +on: + push: + branches: [ "main", "master" ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build Debug APK + run: ./gradlew assembleDebug --stacktrace + + - name: Upload APK Artifact + uses: actions/upload-artifact@v4 + with: + name: PixelPlayer-Debug + path: app/build/outputs/apk/debug/*.apk + diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/GradientTopBar.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/GradientTopBar.kt index a85f6451..78c18519 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/GradientTopBar.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/GradientTopBar.kt @@ -41,6 +41,7 @@ import com.lostf1sh.pixelplayeross.BuildConfig import com.lostf1sh.pixelplayeross.R import com.lostf1sh.pixelplayeross.ui.theme.RoundedSans import com.lostf1sh.pixelplayeross.ui.theme.PixelPlayerStatusBarStyle +import com.lostf1sh.pixelplayeross.ui.theme.liquidGlass import androidx.compose.ui.res.stringResource @OptIn(ExperimentalMaterial3Api::class) @@ -73,16 +74,22 @@ fun GenreGradientTopBar( modifier = Modifier.background(brush = gradientBrush), navigationIcon = { IconButton( - modifier = Modifier.padding(start = 10.dp), + modifier = Modifier + .padding(start = 10.dp) + .liquidGlass( + shape = CircleShape, + tintColor = contentColor, + tintAlpha = 0.22f + ), onClick = onNavigationIconClick, colors = IconButtonDefaults.iconButtonColors( - containerColor = contentColor + containerColor = Color.Transparent ) ) { Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.auth_cd_back), - tint = startColor + tint = contentColor ) } }, @@ -95,7 +102,6 @@ fun GenreGradientTopBar( ) } - @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeGradientTopBar( @@ -126,11 +132,17 @@ fun HomeGradientTopBar( modifier = Modifier.padding(start = 12.dp) ) { FilledTonalButton( - modifier = Modifier.padding(start = 4.dp), + modifier = Modifier + .padding(start = 4.dp) + .liquidGlass( + shape = CircleShape, + tintColor = surfaceContainerHigh, + tintAlpha = 0.52f + ), shape = CircleShape, contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurface ), onClick = onBetaClick @@ -153,20 +165,22 @@ fun HomeGradientTopBar( modifier = Modifier.padding(end = 14.dp) ) { FilledIconButton( + modifier = Modifier.liquidGlass(shape = CircleShape, tintAlpha = 0.50f), colors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurface ), onClick = onStreamingClick ) { Icon( - imageVector = Icons.Rounded.Cloud, - contentDescription = stringResource(R.string.presentation_batch_g_streaming_title) + imageVector = Icons.Rounded.Cloud, + contentDescription = stringResource(R.string.presentation_batch_g_streaming_title) ) } FilledIconButton( + modifier = Modifier.liquidGlass(shape = CircleShape, tintAlpha = 0.50f), colors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurface ), onClick = onMoreOptionsClick @@ -177,8 +191,9 @@ fun HomeGradientTopBar( ) } FilledIconButton( + modifier = Modifier.liquidGlass(shape = CircleShape, tintAlpha = 0.50f), colors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurface ), onClick = onNavigationIconClick diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/PlayerInternalNavigationBar.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/PlayerInternalNavigationBar.kt index e4a1c9b6..bc58c2ff 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/PlayerInternalNavigationBar.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/PlayerInternalNavigationBar.kt @@ -30,6 +30,7 @@ import com.lostf1sh.pixelplayeross.BottomNavItem import com.lostf1sh.pixelplayeross.data.preferences.NavBarStyle import com.lostf1sh.pixelplayeross.presentation.components.scoped.CustomNavigationBarItem import com.lostf1sh.pixelplayeross.presentation.navigation.Screen +import com.lostf1sh.pixelplayeross.ui.theme.liquidGlass import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -114,7 +115,12 @@ private fun PlayerInternalNavigationItemsRow( .fillMaxWidth() } Row( - modifier = rowModifier, + modifier = rowModifier.liquidGlass( + shape = androidx.compose.foundation.shape.RoundedCornerShape(28.dp), + borderWidth = 1.dp, + tintColor = MaterialTheme.colorScheme.surfaceContainer, + tintAlpha = if (compactMode) 0.58f else 0.50f + ), horizontalArrangement = Arrangement.SpaceAround, verticalAlignment = Alignment.CenterVertically ) { @@ -124,7 +130,7 @@ private fun PlayerInternalNavigationItemsRow( val isSelected = currentRoute != null && currentRoute == item.screen.route val selectedColor = MaterialTheme.colorScheme.primary val unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant - val indicatorColorFromTheme = MaterialTheme.colorScheme.secondaryContainer + val indicatorColorFromTheme = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.72f) val iconPainterResId = if (isSelected && item.selectedIconResId != null && item.selectedIconResId != 0) { item.selectedIconResId diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/player/FullPlayerContent.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/player/FullPlayerContent.kt index 197b4b28..1f2a0607 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/player/FullPlayerContent.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/player/FullPlayerContent.kt @@ -1,5 +1,8 @@ package com.lostf1sh.pixelplayeross.presentation.components.player +import com.lostf1sh.pixelplayeross.presentation.player.* +import com.lostf1sh.pixelplayeross.presentation.components.* + import android.annotation.SuppressLint import android.content.Context import android.content.res.Configuration @@ -24,6 +27,7 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -45,6 +49,7 @@ 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.layout.widthIn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ColorScheme @@ -75,7 +80,9 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -90,7 +97,6 @@ import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.gestures.drag -import androidx.compose.foundation.layout.widthIn import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.util.VelocityTracker @@ -153,6 +159,45 @@ private const val SKIP_COMMAND_GUARD_MS = 96L private enum class SkipDirection { PREVIOUS, NEXT } +@Composable +private fun Modifier.liquidGlass( + shape: Shape = RoundedCornerShape(24.dp), + borderWidth: Dp = 1.dp, + tintColor: Color = MaterialTheme.colorScheme.surfaceContainerHighest, + tintAlpha: Float = 0.45f +): Modifier { + val highlightRim = Color.White.copy(alpha = 0.40f) + val shadowRim = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.20f) + + return this + .clip(shape) + .background( + color = tintColor.copy(alpha = tintAlpha), + shape = shape + ) + .background( + brush = Brush.linearGradient( + colors = listOf( + Color.White.copy(alpha = 0.20f), + Color.Transparent, + MaterialTheme.colorScheme.surfaceTint.copy(alpha = 0.08f) + ) + ), + shape = shape + ) + .border( + width = borderWidth, + brush = Brush.verticalGradient( + colors = listOf( + highlightRim, + shadowRim, + highlightRim.copy(alpha = 0.10f) + ) + ), + shape = shape + ) +} + private suspend fun validateLyricsImport( context: Context, uri: Uri @@ -318,7 +363,6 @@ fun FullPlayerContent( val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE - val onLyricsClick = { val lyrics = lyricsProvider() if (lyrics?.synced.isNullOrEmpty() && lyrics?.plain.isNullOrEmpty()) { @@ -372,7 +416,7 @@ fun FullPlayerContent( AddBookmarkDialog( currentProgressMs = bookmarkProgressMs, lyricTitle = lyricBookmarkTitle, - onSave = { title -> + onSave = { title: String -> bookmarksViewModel.addBookmark(song, title, bookmarkProgressMs) showSaveBookmarkDialog = false }, @@ -683,1811 +727,4 @@ fun FullPlayerContent( initialOffsetY = { -it / 2 }, animationSpec = tween(350, easing = FastOutSlowInEasing) ), - exit = fadeOut(animationSpec = tween(220, easing = FastOutSlowInEasing)) + - slideOutVertically( - targetOffsetY = { -it / 2 }, - animationSpec = tween(220, easing = FastOutSlowInEasing) - ) - ) { - TopAppBar( - modifier = Modifier.graphicsLayer { - val fraction = expansionFractionProvider() - val startThreshold = 0f - val endThreshold = 1f - alpha = ((fraction - startThreshold) / (endThreshold - startThreshold)).coerceIn(0f, 1f) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - titleContentColor = LocalMaterialTheme.current.onPrimaryContainer, - ), - title = { - if (!isOutputConnecting) { - AnimatedVisibility(visible = (!isExternalOutputActive)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - modifier = Modifier.padding(start = 18.dp), - text = stringResource(R.string.setcat_now_playing), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.labelLargeEmphasized, - fontWeight = FontWeight.SemiBold - ) - - if (currentSong != null && currentSong.contentUriString.startsWith("http")) { - Icon( - imageVector = androidx.compose.material.icons.Icons.Rounded.Cloud, - contentDescription = stringResource(R.string.presentation_batch_g_player_cd_cloud_stream), - tint = LocalMaterialTheme.current.onPrimaryContainer.copy(alpha = 0.6f), - modifier = Modifier.padding(start = 8.dp).size(16.dp) - ) - } - } - } - } - }, - navigationIcon = { - Box( - modifier = Modifier - .width(56.dp) - .height(42.dp), - contentAlignment = Alignment.CenterEnd - ) { - Box( - modifier = Modifier - .size(42.dp) - .clip(CircleShape) - .background(playerOnAccentColor.copy(alpha = 0.7f)) - .clickable(onClick = onCollapse), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(R.drawable.rounded_keyboard_arrow_down_24), - contentDescription = stringResource(R.string.presentation_batch_g_player_cd_collapse), - tint = playerAccentColor - ) - } - } - }, - actions = { - Row( - modifier = Modifier - .padding(end = 14.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(42.dp) - .clip(CircleShape) - .background(playerOnAccentColor.copy(alpha = 0.7f)) - .clickable { showSaveBookmarkDialog = true }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Rounded.Bookmark, - contentDescription = stringResource(R.string.audio_bookmarks_cd_add), - tint = playerAccentColor - ) - } - Box( - modifier = Modifier - .size(42.dp) - .clip(CircleShape) - .background(playerOnAccentColor.copy(alpha = 0.7f)) - .clickable { - showSongInfoBottomSheet = true - onShowQueueClicked() - }, - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(R.drawable.rounded_queue_music_24), - contentDescription = stringResource(R.string.presentation_batch_g_player_cd_queue), - tint = playerAccentColor - ) - } - } - } - ) - } - } - ) { paddingValues -> - var contentVisible by remember(isLandscape) { mutableStateOf(false) } - LaunchedEffect(isLandscape) { contentVisible = true } - val contentAlpha by animateFloatAsState( - targetValue = if (contentVisible) 1f else 0f, - animationSpec = tween(durationMillis = 380, easing = FastOutSlowInEasing), - label = "orientationAlpha" - ) - Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { alpha = contentAlpha } - ) { - if (isLandscape) { - FullPlayerLandscapeContent( - paddingValues = paddingValues, - albumCoverSection = albumCoverSection, - songMetadataSection = landscapeSongMetadataSection, - playerProgressSection = playerProgressSection, - controlsSection = controlsSection - ) - } else { - FullPlayerPortraitContent( - paddingValues = paddingValues, - albumCoverSection = albumCoverSection, - songMetadataSection = portraitSongMetadataSection, - playerProgressSection = playerProgressSection, - controlsSection = controlsSection - ) - } - } - } - AnimatedVisibility( - visible = showLyricsSheet, - enter = slideInVertically( - initialOffsetY = { it / 5 }, - animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing) - ) + fadeIn(animationSpec = tween(durationMillis = 160)), - exit = slideOutVertically( - targetOffsetY = { it / 6 }, - animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing) - ) + fadeOut(animationSpec = tween(durationMillis = 120)) - ) { - LyricsSheet( - stablePlayerStateFlow = playerViewModel.stablePlayerState, - playbackPositionFlow = playerViewModel.currentPlaybackPosition, - lyricsSearchUiState = lyricsSearchUiState, - resetLyricsForCurrentSong = { - showLyricsSheet = false - playerViewModel.resetLyricsForCurrentSong() - }, - onSearchLyrics = { forcePick -> playerViewModel.fetchLyricsForCurrentSong(forcePick) }, - onPickResult = { playerViewModel.acceptLyricsSearchResultForCurrentSong(it) }, - onManualSearch = { title, artist -> playerViewModel.searchLyricsManually(title, artist) }, - onImportLyrics = { filePickerLauncher.launch(com.lostf1sh.pixelplayeross.utils.LyricsImportSecurity.pickerMimeTypes()) }, - onDismissLyricsSearch = { playerViewModel.resetLyricsSearchState() }, - lyricsSyncOffset = lyricsSyncOffset, - onLyricsSyncOffsetChange = { currentSong?.id?.let { songId -> playerViewModel.setLyricsSyncOffset(songId, it) } }, - lyricsTextStyle = MaterialTheme.typography.titleLarge, - colorScheme = LocalMaterialTheme.current, - onBackClick = { showLyricsSheet = false }, - onSaveLyricsToFile = playerViewModel::saveLyricsToFile, - onSeekTo = { playerViewModel.seekTo(it) }, - onPlayPause = { - playerViewModel.playPause() - }, - onNext = onNext, - onPrev = onPrevious, - immersiveLyricsEnabled = immersiveLyricsEnabled, - immersiveLyricsTimeout = immersiveLyricsTimeout, - isImmersiveTemporarilyDisabled = isImmersiveTemporarilyDisabled, - onSetImmersiveTemporarilyDisabled = { playerViewModel.setImmersiveTemporarilyDisabled(it) }, - isShuffleEnabled = isShuffleEnabled, - repeatMode = repeatMode, - isFavoriteProvider = isFavoriteProvider, - onShuffleToggle = onShuffleToggle, - onRepeatToggle = onRepeatToggle, - onFavoriteToggle = onFavoriteToggle - ) - } - - val artistPickerSheetState = rememberModalSheetState(skipPartiallyExpanded = true) - if (showArtistPicker && currentSongArtists.isNotEmpty()) { - PlayerArtistPickerBottomSheet( - song = song, - artists = currentSongArtists, - sheetState = artistPickerSheetState, - onDismiss = { showArtistPicker = false }, - onArtistClick = { artist -> - playerViewModel.triggerArtistNavigationFromPlayer(artist.id) - showArtistPicker = false - } - ) - } -} - - -@SuppressLint("UnusedBoxWithConstraintsScope") -@Composable -private fun FullPlayerAlbumCoverSection( - song: Song, - currentPlaybackQueue: ImmutableList, - currentMediaItemIndex: Int, - carouselStyle: String, - loadingTweaks: FullPlayerLoadingTweaks, - isSheetDragGestureActive: Boolean, - expansionFractionProvider: () -> Float, - currentSheetState: PlayerSheetState, - isPlayingProvider: () -> Boolean, - playWhenReadyProvider: () -> Boolean, - placeholderColor: Color, - placeholderOnColor: Color, - albumArtQuality: AlbumArtQuality, - requestedScrollIndex: Int?, - onSongSelected: (Song, Int) -> Unit, - onAlbumClick: (Song) -> Unit, - modifier: Modifier = Modifier -) { - val shouldDelay = loadingTweaks.delayAll || loadingTweaks.delayAlbumCarousel - val shouldApplyPausedScale = !isPlayingProvider() && !playWhenReadyProvider() - val albumArtScale by animateFloatAsState( - targetValue = if (shouldApplyPausedScale) 0.95f else 1f, - animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing), - label = "AlbumArtScale" - ) - - BoxWithConstraints( - modifier = modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) { - val carouselHeight = when (carouselStyle) { - CarouselStyle.NO_PEEK -> maxWidth - CarouselStyle.ONE_PEEK -> maxWidth * 0.8f - CarouselStyle.TWO_PEEK -> maxWidth * 0.6f - else -> maxWidth * 0.8f - } - - DelayedContent( - shouldDelay = shouldDelay, - showPlaceholders = loadingTweaks.showPlaceholders, - applyPlaceholderDelayOnClose = loadingTweaks.applyPlaceholdersOnClose, - switchOnDragRelease = loadingTweaks.switchOnDragRelease, - isSheetDragGestureActive = isSheetDragGestureActive, - sharedBoundsModifier = Modifier.fillMaxWidth().height(carouselHeight), - expansionFractionProvider = expansionFractionProvider, - isExpandedOverride = currentSheetState == PlayerSheetState.EXPANDED, - normalStartThreshold = 0.08f, - delayAppearThreshold = loadingTweaks.contentAppearThresholdPercent / 100f, - delayCloseThreshold = 1f - (loadingTweaks.contentCloseThresholdPercent / 100f), - placeholder = { - if (loadingTweaks.transparentPlaceholders) { - Box( - Modifier - .height(carouselHeight) - .fillMaxWidth() - .graphicsLayer { - scaleX = albumArtScale - scaleY = albumArtScale - } - ) - } else { - AlbumPlaceholder( - height = carouselHeight, - color = placeholderColor, - onColor = placeholderOnColor, - modifier = Modifier.graphicsLayer { - scaleX = albumArtScale - scaleY = albumArtScale - } - ) - } - } - ) { - AlbumCarouselSection( - currentSong = song, - queue = currentPlaybackQueue, - expansionFraction = 1f, - currentMediaItemIndex = currentMediaItemIndex, - requestedScrollIndex = requestedScrollIndex, - onSongSelected = { newSong, index -> - if (newSong.id != song.id || index != currentMediaItemIndex) { - onSongSelected(newSong, index) - } - }, - onAlbumClick = onAlbumClick, - carouselStyle = carouselStyle, - modifier = Modifier - .height(carouselHeight) - .graphicsLayer { - scaleX = albumArtScale - scaleY = albumArtScale - }, - albumArtQuality = albumArtQuality - ) - } - } -} - -@Composable -private fun FullPlayerControlsSection( - loadingTweaks: FullPlayerLoadingTweaks, - isSheetDragGestureActive: Boolean, - expansionFractionProvider: () -> Float, - currentSheetState: PlayerSheetState, - placeholderColor: Color, - placeholderOnColor: Color, - isPlayingProvider: () -> Boolean, - onPrevious: () -> Unit, - onPlayPause: () -> Unit, - onNext: () -> Unit, - transportPlayPauseColors: TransportButtonColors, - transportSkipColors: TransportButtonColors, - isShuffleEnabledProvider: () -> Boolean, - shuffleTransitionInProgress: Boolean, - repeatModeProvider: () -> Int, - isFavoriteProvider: () -> Boolean, - onShuffleToggle: () -> Unit, - onRepeatToggle: () -> Unit, - onFavoriteToggle: () -> Unit -) { - val stableControlAnimationSpec = remember { - tween(durationMillis = 240, easing = FastOutSlowInEasing) - } - val shouldDelay = loadingTweaks.delayAll || loadingTweaks.delayControls - - DelayedContent( - shouldDelay = shouldDelay, - showPlaceholders = loadingTweaks.showPlaceholders, - applyPlaceholderDelayOnClose = loadingTweaks.applyPlaceholdersOnClose, - switchOnDragRelease = loadingTweaks.switchOnDragRelease, - isSheetDragGestureActive = isSheetDragGestureActive, - sharedBoundsModifier = Modifier.fillMaxWidth().height(182.dp), - expansionFractionProvider = expansionFractionProvider, - isExpandedOverride = currentSheetState == PlayerSheetState.EXPANDED, - normalStartThreshold = 0.42f, - delayAppearThreshold = loadingTweaks.contentAppearThresholdPercent / 100f, - delayCloseThreshold = 1f - (loadingTweaks.contentCloseThresholdPercent / 100f), - placeholder = { - if (loadingTweaks.transparentPlaceholders) { - Box(Modifier.fillMaxWidth().height(182.dp)) - } else { - ControlsPlaceholder(placeholderColor, placeholderOnColor) - } - } - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - AnimatedPlaybackControls( - modifier = Modifier - .padding(horizontal = 12.dp, vertical = 8.dp), - isPlayingProvider = isPlayingProvider, - onPrevious = onPrevious, - onPlayPause = onPlayPause, - onNext = onNext, - height = 80.dp, - pressAnimationSpec = stableControlAnimationSpec, - releaseDelay = 220L, - colorOtherButtons = transportSkipColors.container, - colorPlayPause = transportPlayPauseColors.container, - tintPlayPauseIcon = transportPlayPauseColors.content, - tintOtherIcons = transportSkipColors.content, - colorPreviousButton = transportSkipColors.container, - colorNextButton = transportSkipColors.container, - tintPreviousIcon = transportSkipColors.content, - tintNextIcon = transportSkipColors.content - ) - - Spacer(modifier = Modifier.height(14.dp)) - - BottomToggleRow( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 66.dp, max = 86.dp) - .padding(horizontal = 26.dp, vertical = 0.dp) - .padding(bottom = 6.dp), - isShuffleEnabled = isShuffleEnabledProvider(), - isShuffleTransitionInProgress = shuffleTransitionInProgress, - repeatMode = repeatModeProvider(), - isFavoriteProvider = isFavoriteProvider, - onShuffleToggle = onShuffleToggle, - onRepeatToggle = onRepeatToggle, - onFavoriteToggle = onFavoriteToggle - ) - } - } -} - -@Composable -private fun FullPlayerProgressSection( - song: Song, - playbackMetadataMediaId: String?, - playbackMetadataMimeType: String?, - playbackMetadataBitrate: Int?, - playbackMetadataSampleRate: Int?, - currentPositionProvider: () -> Long, - totalDurationValue: Long, - showPlayerFileInfo: Boolean, - onSeek: (Long) -> Unit, - expansionFractionProvider: () -> Float, - isPlayingProvider: () -> Boolean, - currentSheetState: PlayerSheetState, - progressActiveColor: Color, - playerOnBaseColor: Color, - allowRealtimeUpdates: Boolean, - isSheetDragGestureActive: Boolean, - loadingTweaks: FullPlayerLoadingTweaks -) { - val isMetadataForCurrentSong = playbackMetadataMediaId == song.id - val audioMimeType = if (isMetadataForCurrentSong) { - playbackMetadataMimeType ?: song.mimeType - } else { - song.mimeType - } - val audioBitrate = if (isMetadataForCurrentSong) { - playbackMetadataBitrate ?: song.bitrate - } else { - song.bitrate - } - val audioSampleRate = if (isMetadataForCurrentSong) { - playbackMetadataSampleRate ?: song.sampleRate - } else { - song.sampleRate - } - - PlayerProgressBarSection( - songId = song.id, - currentPositionProvider = currentPositionProvider, - totalDurationValue = totalDurationValue, - songDurationHintMs = song.duration, - audioMimeType = audioMimeType, - audioBitrate = audioBitrate, - audioSampleRate = audioSampleRate, - showAudioFileInfo = showPlayerFileInfo, - onSeek = onSeek, - expansionFractionProvider = expansionFractionProvider, - isPlayingProvider = isPlayingProvider, - currentSheetState = currentSheetState, - activeTrackColor = progressActiveColor, - inactiveTrackColor = playerOnBaseColor.copy(alpha = 0.2f), - thumbColor = progressActiveColor, - timeTextColor = playerOnBaseColor, - allowRealtimeUpdates = allowRealtimeUpdates, - isSheetDragGestureActive = isSheetDragGestureActive, - loadingTweaks = loadingTweaks - ) -} - -private fun resolveQueueIndex( - queue: ImmutableList, - songId: String, - currentMediaItemIndex: Int -): Int? { - if (currentMediaItemIndex in queue.indices && queue[currentMediaItemIndex].id == songId) { - return currentMediaItemIndex - } - return queue.indexOfFirst { it.id == songId }.takeIf { it >= 0 } -} - -private fun predictSkipNextCarouselIndex( - currentIndex: Int?, - queue: ImmutableList, - repeatMode: Int, - isExternalOutputActive: Boolean -): Int? { - if (isExternalOutputActive || queue.size <= 1) return null - val safeCurrentIndex = currentIndex?.takeIf { it in queue.indices } ?: return null - - return when { - safeCurrentIndex < queue.lastIndex -> safeCurrentIndex + 1 - repeatMode == Player.REPEAT_MODE_ALL -> 0 - else -> null - } -} - -private fun predictSkipPreviousCarouselIndex( - currentIndex: Int?, - queue: ImmutableList, - currentPositionMs: Long, - repeatMode: Int, - isExternalOutputActive: Boolean -): Int? { - if (isExternalOutputActive || queue.size <= 1) return null - if (currentPositionMs > PREVIOUS_TRACK_RESTART_THRESHOLD_MS) return null - val safeCurrentIndex = currentIndex?.takeIf { it in queue.indices } ?: return null - - return when { - safeCurrentIndex > 0 -> safeCurrentIndex - 1 - repeatMode == Player.REPEAT_MODE_ALL -> queue.lastIndex - else -> null - } -} - -@androidx.annotation.OptIn(UnstableApi::class) -@Composable -private fun FullPlayerSongMetadataSection( - song: Song, - currentSongArtists: ImmutableList, - loadingTweaks: FullPlayerLoadingTweaks, - isSheetDragGestureActive: Boolean, - expansionFractionProvider: () -> Float, - currentSheetState: PlayerSheetState, - placeholderColor: Color, - placeholderOnColor: Color, - isLandscape: Boolean, - onLyricsClick: () -> Unit, - playerOnBaseColor: Color, - playerViewModel: PlayerViewModel, - gradientEdgeColor: Color, - chipColor: Color, - chipContentColor: Color, - onQueueClick: () -> Unit, - onArtistClick: () -> Unit, - isPlayingProvider: () -> Boolean = { true } -) { - val shouldDelay = loadingTweaks.delayAll || loadingTweaks.delaySongMetadata - - DelayedContent( - shouldDelay = shouldDelay, - showPlaceholders = loadingTweaks.showPlaceholders, - applyPlaceholderDelayOnClose = loadingTweaks.applyPlaceholdersOnClose, - switchOnDragRelease = loadingTweaks.switchOnDragRelease, - isSheetDragGestureActive = isSheetDragGestureActive, - sharedBoundsModifier = Modifier.fillMaxWidth().heightIn(min = 70.dp), - expansionFractionProvider = expansionFractionProvider, - isExpandedOverride = currentSheetState == PlayerSheetState.EXPANDED, - normalStartThreshold = 0.20f, - delayAppearThreshold = loadingTweaks.contentAppearThresholdPercent / 100f, - delayCloseThreshold = 1f - (loadingTweaks.contentCloseThresholdPercent / 100f), - placeholder = { - if (loadingTweaks.transparentPlaceholders) { - Box(Modifier.fillMaxWidth().height(70.dp)) - } else { - MetadataPlaceholder( - expansionFractionProvider = expansionFractionProvider, - color = placeholderColor, - onColor = placeholderOnColor, - showQueueButtons = isLandscape - ) - } - } - ) { - SongMetadataDisplaySection( - modifier = Modifier - .padding(start = 0.dp), - onClickLyrics = onLyricsClick, - song = song, - currentSongArtists = currentSongArtists, - expansionFractionProvider = expansionFractionProvider, - textColor = playerOnBaseColor, - artistTextColor = playerOnBaseColor.copy(alpha = 0.7f), - playerViewModel = playerViewModel, - gradientEdgeColor = gradientEdgeColor, - chipColor = chipColor, - chipContentColor = chipContentColor, - showQueueButton = isLandscape, - onClickQueue = onQueueClick, - onClickArtist = onArtistClick, - isPlayingProvider = isPlayingProvider - ) - } -} - -@Composable -private fun FullPlayerPortraitContent( - paddingValues: PaddingValues, - albumCoverSection: @Composable (Modifier) -> Unit, - songMetadataSection: @Composable () -> Unit, - playerProgressSection: @Composable () -> Unit, - controlsSection: @Composable () -> Unit -) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding( - horizontal = 24.dp, - vertical = 0.dp - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceAround - ) { - albumCoverSection(Modifier) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Box(Modifier.align(Alignment.Start)) { - songMetadataSection() - } - playerProgressSection() - } - - controlsSection() - } -} - -@Composable -private fun FullPlayerLandscapeContent( - paddingValues: PaddingValues, - albumCoverSection: @Composable (Modifier) -> Unit, - songMetadataSection: @Composable () -> Unit, - playerProgressSection: @Composable () -> Unit, - controlsSection: @Composable () -> Unit -) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding( - horizontal = 24.dp, - vertical = 0.dp - ), - verticalAlignment = Alignment.CenterVertically - ) { - albumCoverSection( - Modifier - .fillMaxHeight() - .weight(1f) - ) - Spacer(Modifier.width(9.dp)) - Column( - modifier = Modifier - .fillMaxHeight() - .weight(1f) - .padding( - horizontal = 0.dp, - vertical = 0.dp - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceEvenly - ) { - songMetadataSection() - playerProgressSection() - controlsSection() - } - } -} - -@androidx.annotation.OptIn(UnstableApi::class) -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) -@Composable -private fun SongMetadataDisplaySection( - song: Song?, - currentSongArtists: ImmutableList, - expansionFractionProvider: () -> Float, - textColor: Color, - artistTextColor: Color, - gradientEdgeColor: Color, - playerViewModel: PlayerViewModel, - chipColor: Color, - chipContentColor: Color, - onClickLyrics: () -> Unit, - showQueueButton: Boolean, - onClickQueue: () -> Unit, - onClickArtist: () -> Unit, - modifier: Modifier = Modifier, - isPlayingProvider: () -> Boolean = { true } -) { - Row( - modifier - .fillMaxWidth() - .heightIn(min = 70.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - song?.let { currentSong -> - PlayerSongInfo( - title = currentSong.title, - artist = currentSong.displayArtist, - artistId = currentSong.artistId, - artists = currentSongArtists, - expansionFractionProvider = expansionFractionProvider, - textColor = textColor, - artistTextColor = artistTextColor, - gradientEdgeColor = gradientEdgeColor, - playerViewModel = playerViewModel, - onClickArtist = onClickArtist, - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically), - isPlayingProvider = isPlayingProvider - ) - } - - val stablePlayerState by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() - val isBuffering = stablePlayerState.isBuffering - - - AnimatedVisibility( - visible = isBuffering, - enter = scaleIn( - initialScale = 0.85f, - animationSpec = tween( - durationMillis = 400, - delayMillis = 80, - easing = FastOutSlowInEasing - ) - ) + fadeIn( - animationSpec = tween( - durationMillis = 300, - delayMillis = 80 - ) - ), - exit = scaleOut( - targetScale = 0.85f, - animationSpec = tween( - durationMillis = 300, - easing = FastOutSlowInEasing - ) - ) + fadeOut( - animationSpec = tween( - durationMillis = 200 - ) - ) - ) { - Surface( - shape = CircleShape, - color = chipColor, - modifier = Modifier.padding(end = 8.dp) - ) { - Box( - modifier = Modifier.padding(10.dp), - contentAlignment = Alignment.Center - ) { - LoadingIndicator( - modifier = Modifier.size(28.dp), - color = chipContentColor - ) - } - } - } - - if (showQueueButton) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(height = 42.dp, width = 50.dp) - .clip( - RoundedCornerShape( - topStart = 50.dp, - topEnd = 6.dp, - bottomStart = 50.dp, - bottomEnd = 6.dp - ) - ) - .background(chipColor) - .clickable { onClickLyrics() }, - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(R.drawable.rounded_lyrics_24), - contentDescription = stringResource(R.string.presentation_batch_g_player_cd_lyrics), - tint = chipContentColor - ) - } - Box( - modifier = Modifier - .size(height = 42.dp, width = 50.dp) - .clip( - RoundedCornerShape( - topStart = 6.dp, - topEnd = 50.dp, - bottomStart = 6.dp, - bottomEnd = 50.dp - ) - ) - .background(chipColor) - .clickable { onClickQueue() }, - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(R.drawable.rounded_queue_music_24), - contentDescription = stringResource(R.string.presentation_batch_g_player_cd_queue), - tint = chipContentColor - ) - } - } - } else { - FilledIconButton( - modifier = Modifier - .size(width = 48.dp, height = 48.dp), - colors = IconButtonDefaults.filledIconButtonColors( - containerColor = chipColor, - contentColor = chipContentColor - ), - onClick = onClickLyrics, - ) { - Icon( - painter = painterResource(R.drawable.rounded_lyrics_24), - contentDescription = stringResource(R.string.presentation_batch_g_player_cd_lyrics) - ) - } - } - } -} - -private fun formatAudioMetaLabel(mimeType: String?, bitrate: Int?, sampleRate: Int?): String? { - val formatLabel = mimeTypeToFormat(mimeType) - .takeIf { it != "-" } - ?.uppercase(Locale.getDefault()) - - val parts = buildList { - sampleRate?.takeIf { it > 0 }?.let { add(String.format(Locale.US, "%.1f kHz", it / 1000.0)) } - bitrate?.takeIf { it > 0 }?.let { bitrateValue -> - val kbpsLabel = "${bitrateValue / 1000} kbps" - if (formatLabel != null) { - add("$kbpsLabel \u2022 $formatLabel") - } else { - add(kbpsLabel) - } - } ?: formatLabel?.let { add(it) } - } - return parts.takeIf { it.isNotEmpty() }?.joinToString(" \u2022 ") -} - -@Composable -private fun PlayerProgressBarSection( - songId: String, - currentPositionProvider: () -> Long, - totalDurationValue: Long, - songDurationHintMs: Long, - audioMimeType: String?, - audioBitrate: Int?, - audioSampleRate: Int?, - showAudioFileInfo: Boolean, - onSeek: (Long) -> Unit, - expansionFractionProvider: () -> Float, - isPlayingProvider: () -> Boolean, - currentSheetState: PlayerSheetState, - activeTrackColor: Color, - inactiveTrackColor: Color, - thumbColor: Color, - timeTextColor: Color, - allowRealtimeUpdates: Boolean = true, - isSheetDragGestureActive: Boolean = false, - loadingTweaks: FullPlayerLoadingTweaks? = null, - modifier: Modifier = Modifier -) { - val progressSectionHorizontalInset = 0.dp - val isVisible by remember(expansionFractionProvider) { - derivedStateOf { expansionFractionProvider() > 0.01f } - } - val isExpanded by remember(currentSheetState, expansionFractionProvider) { - derivedStateOf { - currentSheetState == PlayerSheetState.EXPANDED && expansionFractionProvider() >= 0.995f - } - } - val shouldRunRealtimeUpdates = allowRealtimeUpdates && isVisible - val shouldSampleProgress = isVisible - - val reportedDuration = totalDurationValue.coerceAtLeast(0L) - val hintDuration = songDurationHintMs.coerceAtLeast(0L) - val displayDurationValue = when { - reportedDuration <= 0L && hintDuration <= 0L -> 0L - reportedDuration <= 0L -> hintDuration - hintDuration <= 0L -> reportedDuration - kotlin.math.abs(reportedDuration - hintDuration) <= 1500L -> reportedDuration - else -> minOf(reportedDuration, hintDuration) - } - val audioMetaLabel = remember(showAudioFileInfo, audioMimeType, audioBitrate, audioSampleRate) { - if (showAudioFileInfo) { - formatAudioMetaLabel( - mimeType = audioMimeType, - bitrate = audioBitrate, - sampleRate = audioSampleRate - ) - } else { - null - } - } - var displayAudioMetaLabel by remember(songId) { mutableStateOf(null) } - LaunchedEffect(songId, audioMetaLabel, showAudioFileInfo) { - if (!showAudioFileInfo) { - displayAudioMetaLabel = null - } else if (!audioMetaLabel.isNullOrBlank()) { - displayAudioMetaLabel = audioMetaLabel - } else { - kotlinx.coroutines.delay(500) - displayAudioMetaLabel = null - } - } - val durationForCalc = displayDurationValue.coerceAtLeast(1L) - - val (smoothProgressState, _) = rememberSmoothProgress( - isPlayingProvider = isPlayingProvider, - currentPositionProvider = currentPositionProvider, - totalDuration = displayDurationValue, - sampleWhilePlayingMs = if (shouldRunRealtimeUpdates && isExpanded) 180L else 500L, - sampleWhilePausedMs = 800L, - isVisible = shouldSampleProgress - ) - - var sliderDragValue by remember { mutableStateOf(null) } - var targetSeekFraction by remember { mutableFloatStateOf(-1f) } - var lastSeekFinishedTime by remember { mutableLongStateOf(0L) } - - LaunchedEffect(songId) { - sliderDragValue = null - targetSeekFraction = -1f - lastSeekFinishedTime = 0L - } - - LaunchedEffect(songId) { - snapshotFlow { smoothProgressState.value }.collect { progress -> - if (sliderDragValue != null) return@collect - val target = targetSeekFraction - if (target < 0f) return@collect - val timeSinceSeek = System.currentTimeMillis() - lastSeekFinishedTime - val diff = kotlin.math.abs(progress - target) - if (timeSinceSeek > 5000L || diff < 0.04f) { - targetSeekFraction = -1f - } - } - } - - val interactionSource = remember { MutableInteractionSource() } - val shouldAnimateWavyProgress by remember(shouldRunRealtimeUpdates, isPlayingProvider) { - derivedStateOf { shouldRunRealtimeUpdates && isPlayingProvider() } - } - - val animatedProgressState = remember(smoothProgressState) { - derivedStateOf { - when { - sliderDragValue != null -> sliderDragValue!! - targetSeekFraction >= 0f -> targetSeekFraction - else -> smoothProgressState.value - } - } - } - - val effectivePositionState = remember(durationForCalc, animatedProgressState, isVisible, displayDurationValue) { - derivedStateOf { - val progress = animatedProgressState.value - (progress * durationForCalc).roundToLong().coerceIn(0L, displayDurationValue) - } - } - - val shouldDelay = loadingTweaks?.let { it.delayAll || it.delayProgressBar } ?: false - - val placeholderColor = LocalMaterialTheme.current.onPrimaryContainer.copy(alpha = 0.25f) - val placeholderOnColor = LocalMaterialTheme.current.onPrimaryContainer.copy(alpha = 0.2f) - - DelayedContent( - shouldDelay = shouldDelay, - showPlaceholders = loadingTweaks?.showPlaceholders ?: false, - applyPlaceholderDelayOnClose = loadingTweaks?.applyPlaceholdersOnClose ?: true, - switchOnDragRelease = loadingTweaks?.switchOnDragRelease ?: false, - isSheetDragGestureActive = isSheetDragGestureActive, - sharedBoundsModifier = Modifier.fillMaxWidth().heightIn(min = 70.dp), - expansionFractionProvider = expansionFractionProvider, - isExpandedOverride = currentSheetState == PlayerSheetState.EXPANDED, - normalStartThreshold = 0.08f, - delayAppearThreshold = (loadingTweaks?.contentAppearThresholdPercent ?: 0) / 100f, - delayCloseThreshold = 1f - ((loadingTweaks?.contentCloseThresholdPercent ?: 0) / 100f), - placeholder = { - if (loadingTweaks?.transparentPlaceholders == true) { - Box(Modifier.fillMaxWidth().heightIn(min = 70.dp)) - } else { - ProgressPlaceholder( - color = placeholderColor, - onColor = placeholderOnColor, - showAudioMetaChip = showAudioFileInfo && !displayAudioMetaLabel.isNullOrBlank() - ) - } - } - ) { - Column( - modifier = modifier - .fillMaxWidth() - .heightIn(min = 70.dp) - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .pointerInput(Unit) { - detectVerticalDragGestures(onVerticalDrag = { _, _ -> }) - } - ) { - EfficientSlider( - valueState = animatedProgressState, - onValueChange = { sliderDragValue = it }, - onValueCommit = { finalValue -> - val targetMs = (finalValue * durationForCalc).roundToLong() - targetSeekFraction = finalValue - lastSeekFinishedTime = System.currentTimeMillis() - onSeek(targetMs) - sliderDragValue = null - }, - thumbColor = thumbColor, - activeTrackColor = activeTrackColor, - inactiveTrackColor = inactiveTrackColor, - interactionSource = interactionSource, - isPlaying = shouldAnimateWavyProgress, - isVisible = isVisible, - trackEdgePadding = progressSectionHorizontalInset - ) - } - - EfficientTimeLabels( - positionState = effectivePositionState, - duration = displayDurationValue, - isVisible = isVisible, - textColor = timeTextColor, - audioMetaLabel = displayAudioMetaLabel, - horizontalTrackInset = progressSectionHorizontalInset - ) - } - } -} - -@Composable -private fun EfficientSlider( - valueState: androidx.compose.runtime.State, - onValueChange: (Float) -> Unit, - onValueCommit: (Float) -> Unit, - thumbColor: Color, - activeTrackColor: Color, - inactiveTrackColor: Color, - interactionSource: MutableInteractionSource, - isPlaying: Boolean, - isVisible: Boolean, - trackEdgePadding: Dp -) { - val haptics = LocalHapticFeedback.current - val currentOnValueChange = rememberUpdatedState(onValueChange) - val currentHaptics = rememberUpdatedState(haptics) - val lastHapticStep = remember { intArrayOf(-1) } - val onValueChangeWithHaptics = remember { - { newValue: Float -> - val quantized = (newValue.coerceIn(0f, 1f) * 20f).toInt() - if (quantized != lastHapticStep[0]) { - lastHapticStep[0] = quantized - currentHaptics.value.performHapticFeedback(HapticFeedbackType.TextHandleMove) - } - currentOnValueChange.value(newValue) - } - } - - WavySliderExpressive( - value = { valueState.value }, - onValueChange = onValueChangeWithHaptics, - onValueCommit = onValueCommit, - interactionSource = interactionSource, - activeTrackColor = activeTrackColor, - inactiveTrackColor = inactiveTrackColor, - thumbColor = thumbColor, - isPlaying = isPlaying, - isVisible = isVisible, - trackEdgePadding = trackEdgePadding, - semanticsLabel = "Playback position", - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp, horizontal = 0.dp) - ) -} - -@Composable -private fun EfficientTimeLabels( - positionState: androidx.compose.runtime.State, - duration: Long, - isVisible: Boolean, - textColor: Color, - audioMetaLabel: String?, - horizontalTrackInset: Dp -) { - val coarsePositionMs by remember(isVisible, positionState) { - derivedStateOf { - if (!isVisible) 0L - else (positionState.value.coerceAtLeast(0L) / 1000L) * 1000L - } - } - val posStr by remember(isVisible, coarsePositionMs) { - derivedStateOf { if (isVisible) formatDuration(coarsePositionMs) else "--:--" } - } - val durStr = remember(isVisible, duration) { - if (isVisible) formatDuration(duration.coerceAtLeast(0L)) else "--:--" - } - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalTrackInset) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - posStr, - style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), - fontWeight = FontWeight.SemiBold, - color = textColor - ) - Text( - durStr, - style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), - fontWeight = FontWeight.SemiBold, - color = textColor - ) - } - - if (!audioMetaLabel.isNullOrBlank()) { - Surface( - modifier = Modifier - .align(Alignment.Center) - .padding(horizontal = 58.dp), - shape = RoundedCornerShape(999.dp), - color = textColor.copy(alpha = 0.14f), - contentColor = textColor.copy(alpha = 0.96f) - ) { - Text( - text = audioMetaLabel, - style = MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Medium, - fontSize = 11.sp - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 3.dp) - ) - } - } - } -} - -@Composable -private fun DelayedContent( - shouldDelay: Boolean, - showPlaceholders: Boolean, - applyPlaceholderDelayOnClose: Boolean, - switchOnDragRelease: Boolean, - isSheetDragGestureActive: Boolean, - sharedBoundsModifier: Modifier = Modifier, - expansionFractionProvider: () -> Float, - isExpandedOverride: Boolean = false, - normalStartThreshold: Float, - delayAppearThreshold: Float, - delayCloseThreshold: Float, - placeholder: @Composable () -> Unit, - content: @Composable () -> Unit -) { - val appearThreshold = delayAppearThreshold.coerceIn(0f, 1f) - val closeThreshold = delayCloseThreshold.coerceIn(0f, 1f) - var isDelayGateOpen by remember(shouldDelay) { mutableStateOf(!shouldDelay) } - - LaunchedEffect( - shouldDelay, - appearThreshold, - closeThreshold, - applyPlaceholderDelayOnClose, - switchOnDragRelease, - isSheetDragGestureActive, - isExpandedOverride, - expansionFractionProvider - ) { - if (!shouldDelay) { - isDelayGateOpen = true - return@LaunchedEffect - } - - if (switchOnDragRelease) { - if (isSheetDragGestureActive) { - return@LaunchedEffect - } - - if (isExpandedOverride) { - isDelayGateOpen = true - } else { - snapshotFlow { expansionFractionProvider().coerceIn(0f, 1f) } - .first { fraction -> fraction <= 0.001f } - isDelayGateOpen = false - } - return@LaunchedEffect - } - - var previousExpansionFraction = expansionFractionProvider().coerceIn(0f, 1f) - var previousExpandedOverride = isExpandedOverride - - snapshotFlow { - val rawExpansionFraction = expansionFractionProvider().coerceIn(0f, 1f) - val effectiveExpansionFraction = - if (isExpandedOverride && rawExpansionFraction >= 0.985f) 1f else rawExpansionFraction - DelayedContentFrame( - rawExpansionFraction = rawExpansionFraction, - effectiveExpansionFraction = effectiveExpansionFraction, - isExpandedOverride = isExpandedOverride - ) - }.collect { frame -> - val isCollapsingByFraction = - frame.rawExpansionFraction < previousExpansionFraction - 0.001f - val isExpandingByFraction = - frame.rawExpansionFraction > previousExpansionFraction + 0.001f - val justStartedCollapsing = - previousExpandedOverride && !frame.isExpandedOverride - val justStartedExpanding = - !previousExpandedOverride && frame.isExpandedOverride - val isCollapsing = isCollapsingByFraction || justStartedCollapsing - val isExpanding = isExpandingByFraction || justStartedExpanding - val isFullyExpanded = - frame.isExpandedOverride && frame.effectiveExpansionFraction >= 0.985f - - if (frame.effectiveExpansionFraction <= 0.001f && !frame.isExpandedOverride) { - isDelayGateOpen = false - } else if (isFullyExpanded) { - isDelayGateOpen = true - } else if (isDelayGateOpen) { - if (applyPlaceholderDelayOnClose && - isCollapsing && - frame.effectiveExpansionFraction <= closeThreshold - ) { - isDelayGateOpen = false - } - } else if ( - frame.effectiveExpansionFraction >= appearThreshold && - (!applyPlaceholderDelayOnClose || isExpanding || frame.isExpandedOverride) - ) { - isDelayGateOpen = true - } - - previousExpansionFraction = frame.rawExpansionFraction - previousExpandedOverride = frame.isExpandedOverride - } - } - - val baseAlphaProvider = remember(normalStartThreshold, expansionFractionProvider) { - { - ((expansionFractionProvider().coerceIn(0f, 1f) - normalStartThreshold) / - (1f - normalStartThreshold).coerceAtLeast(0.001f)) - .coerceIn(0f, 1f) - } - } - val contentBlendAlpha by animateFloatAsState( - targetValue = if (isDelayGateOpen) 1f else 0f, - animationSpec = if (isDelayGateOpen) { - tween(durationMillis = 260, easing = FastOutSlowInEasing) - } else { - tween(durationMillis = 140, easing = FastOutSlowInEasing) - }, - label = "DelayedContentBlendAlpha" - ) - val placeholderBlendAlpha by animateFloatAsState( - targetValue = if (isDelayGateOpen) 0f else 1f, - animationSpec = if (isDelayGateOpen) { - tween(durationMillis = 360, easing = FastOutSlowInEasing) - } else { - tween(durationMillis = 140, easing = FastOutSlowInEasing) - }, - label = "DelayedPlaceholderBlendAlpha" - ) - - if (shouldDelay) { - Box(modifier = sharedBoundsModifier) { - val shouldComposeContent = isDelayGateOpen - - if (shouldComposeContent) { - Box( - modifier = Modifier.graphicsLayer { - alpha = contentBlendAlpha * baseAlphaProvider() - } - ) { - content() - } - } - if (showPlaceholders && placeholderBlendAlpha > 0.001f) { - Box( - modifier = Modifier.graphicsLayer { alpha = placeholderBlendAlpha } - ) { - placeholder() - } - } - } - } else { - Box( - modifier = sharedBoundsModifier.graphicsLayer { alpha = baseAlphaProvider() } - ) { - content() - } - } -} - -private data class DelayedContentFrame( - val rawExpansionFraction: Float, - val effectiveExpansionFraction: Float, - val isExpandedOverride: Boolean -) - -@androidx.annotation.OptIn(UnstableApi::class) -@Composable -private fun PlayerSongInfo( - title: String, - artist: String, - artistId: Long, - artists: ImmutableList, - expansionFractionProvider: () -> Float, - textColor: Color, - artistTextColor: Color, - gradientEdgeColor: Color, - playerViewModel: PlayerViewModel, - onClickArtist: () -> Unit, - modifier: Modifier = Modifier, - isPlayingProvider: () -> Boolean = { true } -) { - val coroutineScope = rememberCoroutineScope() - var isNavigatingToArtist by remember { mutableStateOf(false) } - val resolvedArtistId by remember(artists, artistId) { - derivedStateOf { artists.firstOrNull { it.id != 0L && it.id != -1L }?.id ?: artistId } - } - val titleStyle = MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.Bold, - fontFamily = RoundedSans, - color = textColor - ) - - val artistStyle = MaterialTheme.typography.titleMedium.copy( - letterSpacing = 0.sp, - color = artistTextColor - ) - - Column( - horizontalAlignment = Alignment.Start, - modifier = modifier - .padding(vertical = 4.dp) - .fillMaxWidth() - .graphicsLayer { - val fraction = expansionFractionProvider() - alpha = fraction - translationY = (1f - fraction) * 24f - } - ) { - AutoScrollingTextOnDemand( - text = title, - style = titleStyle, - gradientEdgeColor = gradientEdgeColor, - expansionFractionProvider = expansionFractionProvider, - modifier = Modifier.fillMaxWidth(), - canScroll = isPlayingProvider() - ) - Spacer(modifier = Modifier.height(2.dp)) - - - - AutoScrollingTextOnDemand( - text = artist, - style = artistStyle, - gradientEdgeColor = gradientEdgeColor, - expansionFractionProvider = expansionFractionProvider, - modifier = Modifier - .fillMaxWidth() - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { - if (isNavigatingToArtist) return@combinedClickable - coroutineScope.launch { - isNavigatingToArtist = true - try { - onClickArtist() - } finally { - isNavigatingToArtist = false - } - } - }, - - onLongClick = { - if (isNavigatingToArtist) return@combinedClickable - coroutineScope.launch { - isNavigatingToArtist = true - try { - playerViewModel.triggerArtistNavigationFromPlayer(resolvedArtistId) - } finally { - isNavigatingToArtist = false - } - } - } - ), - canScroll = isPlayingProvider() - ) - } -} - -@Composable -private fun PlaceholderBox( - modifier: Modifier, - cornerRadius: Dp = 12.dp, - color: Color -) { - Surface( - modifier = modifier, - shape = RoundedCornerShape(cornerRadius), - color = color, - tonalElevation = 0.dp - ) {} -} - -@Composable -private fun AlbumPlaceholder( - height: Dp, - color: Color, - onColor: Color, - modifier: Modifier = Modifier -) { - Surface( - modifier = modifier - .fillMaxWidth() - .height(height), - shape = RoundedCornerShape(18.dp), - color = color, - tonalElevation = 0.dp - ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - Icon( - modifier = Modifier.size(86.dp), - painter = painterResource(R.drawable.pixelplayer_base_monochrome), - contentDescription = null, - tint = onColor - ) - } - } -} - -@Composable -private fun MetadataPlaceholder( - expansionFractionProvider: () -> Float, - color: Color, - onColor: Color, - showQueueButtons: Boolean -) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 70.dp) - .graphicsLayer { - val expansionFraction = expansionFractionProvider().coerceIn(0f, 1f) - alpha = expansionFraction.coerceIn(0f, 1f) - translationY = (1f - expansionFraction.coerceIn(0f, 1f)) * 24f - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Column( - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - PlaceholderBox( - modifier = Modifier - .fillMaxWidth(0.72f) - .height(27.dp), - cornerRadius = 8.dp, - color = color - ) - PlaceholderBox( - modifier = Modifier - .fillMaxWidth(0.46f) - .height(17.dp), - cornerRadius = 8.dp, - color = onColor - ) - } - - if (showQueueButtons) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Surface( - modifier = Modifier.size(height = 42.dp, width = 50.dp), - shape = RoundedCornerShape( - topStart = 50.dp, - topEnd = 6.dp, - bottomStart = 50.dp, - bottomEnd = 6.dp - ), - color = onColor, - tonalElevation = 0.dp - ) {} - Surface( - modifier = Modifier.size(height = 42.dp, width = 50.dp), - shape = RoundedCornerShape( - topStart = 6.dp, - topEnd = 50.dp, - bottomStart = 6.dp, - bottomEnd = 50.dp - ), - color = onColor, - tonalElevation = 0.dp - ) {} - } - } else { - PlaceholderBox( - modifier = Modifier.size(width = 48.dp, height = 48.dp), - cornerRadius = 24.dp, - color = onColor - ) - } - } -} - -@Composable -private fun ProgressPlaceholder( - color: Color, - onColor: Color, - showAudioMetaChip: Boolean -) { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 70.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - contentAlignment = Alignment.CenterStart - ) { - PlaceholderBox( - modifier = Modifier - .fillMaxWidth() - .height(6.dp), - cornerRadius = 3.dp, - color = onColor.copy(alpha = 0.15f) - ) - PlaceholderBox( - modifier = Modifier - .fillMaxWidth(0.34f) - .height(6.dp) - .graphicsLayer { alpha = 0f }, - cornerRadius = 3.dp, - color = color - ) - PlaceholderBox( - modifier = Modifier - .padding(start = 92.dp) - .size(14.dp) - .graphicsLayer { alpha = 0f }, - cornerRadius = 7.dp, - color = onColor - ) - } - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 0.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - PlaceholderBox( - modifier = Modifier - .width(34.dp) - .height(12.dp), - cornerRadius = 2.dp, - color = onColor - ) - PlaceholderBox( - modifier = Modifier - .width(34.dp) - .height(12.dp), - cornerRadius = 2.dp, - color = onColor - ) - } - - if (showAudioMetaChip) { - PlaceholderBox( - modifier = Modifier - .align(Alignment.Center) - .widthIn(min = 96.dp, max = 180.dp) - .height(18.dp), - cornerRadius = 999.dp, - color = onColor.copy(alpha = 0.15f) - ) - } - } - } -} - -@Composable -private fun ControlsPlaceholder(color: Color, onColor: Color) { - val rowCorners = 60.dp - - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .padding(horizontal = 12.dp, vertical = 8.dp) - .fillMaxWidth() - .height(80.dp) - ) { - Row( - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - PlaceholderBox( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - cornerRadius = 60.dp, - color = onColor - ) - Surface( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - shape = AbsoluteSmoothCornerShape( - cornerRadiusTL = rowCorners, - smoothnessAsPercentTR = 60, - cornerRadiusBL = rowCorners, - smoothnessAsPercentTL = 60, - cornerRadiusTR = rowCorners, - smoothnessAsPercentBL = 60, - cornerRadiusBR = rowCorners, - smoothnessAsPercentBR = 60 - ), - color = color, - tonalElevation = 0.dp - ) {} - PlaceholderBox( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - cornerRadius = 60.dp, - color = onColor - ) - } - } - - Spacer(modifier = Modifier.height(14.dp)) - - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 66.dp, max = 86.dp) - .padding(horizontal = 26.dp) - .padding(bottom = 6.dp) - .background( - color = onColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(rowCorners) - ), - contentAlignment = Alignment.Center - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(6.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - repeat(3) { - PlaceholderBox( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - cornerRadius = rowCorners, - color = onColor.copy(alpha = 0.1f) - ) - } - } - } - } -} - -private data class TransportButtonColors( - val container: Color, - val content: Color -) - -private fun expressivePlayPauseButtonColors(colorScheme: ColorScheme): TransportButtonColors { - return TransportButtonColors( - container = colorScheme.tertiaryFixedDim, - content = colorScheme.onTertiaryFixed - ) -} - -private fun expressiveSkipButtonColors(colorScheme: ColorScheme): TransportButtonColors { - return TransportButtonColors( - container = colorScheme.secondaryFixedDim, - content = colorScheme.onSecondaryFixed - ) -} - -@Composable -private fun BottomToggleRow( - modifier: Modifier, - isShuffleEnabled: Boolean, - isShuffleTransitionInProgress: Boolean, - repeatMode: Int, - isFavoriteProvider: () -> Boolean, - onShuffleToggle: () -> Unit, - onRepeatToggle: () -> Unit, - onFavoriteToggle: () -> Unit -) { - val isFavorite = isFavoriteProvider() - val rowCorners = 60.dp - val inactiveBg = LocalMaterialTheme.current.onSurface.copy(alpha = 0.07f) - val inactiveContentColor = LocalMaterialTheme.current.onSurface - - - Box( - modifier = modifier.background( - color = LocalMaterialTheme.current.surfaceContainerLowest.copy(alpha = 0.7f), - shape = AbsoluteSmoothCornerShape( - cornerRadiusBL = rowCorners, - smoothnessAsPercentTR = 60, - cornerRadiusBR = rowCorners, - smoothnessAsPercentBL = 60, - cornerRadiusTL = rowCorners, - smoothnessAsPercentBR = 60, - cornerRadiusTR = rowCorners, - smoothnessAsPercentTL = 60 - ) - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(6.dp) - .clip( - AbsoluteSmoothCornerShape( - cornerRadiusBL = rowCorners, - smoothnessAsPercentTR = 60, - cornerRadiusBR = rowCorners, - smoothnessAsPercentBL = 60, - cornerRadiusTL = rowCorners, - smoothnessAsPercentBR = 60, - cornerRadiusTR = rowCorners, - smoothnessAsPercentTL = 60 - ) - ) - .background(Color.Transparent), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val commonModifier = Modifier.weight(1f) - - ToggleSegmentButton( - modifier = commonModifier, - active = isShuffleEnabled, - enabled = !isShuffleTransitionInProgress, - activeColor = LocalMaterialTheme.current.primaryFixed, - activeCornerRadius = rowCorners, - activeContentColor = LocalMaterialTheme.current.onPrimaryFixed, - inactiveColor = inactiveBg, - inactiveContentColor = inactiveContentColor, - onClick = onShuffleToggle, - iconId = R.drawable.rounded_shuffle_24, - contentDesc = "Shuffle" - ) - val repeatActive = repeatMode != Player.REPEAT_MODE_OFF - val repeatIcon = when (repeatMode) { - Player.REPEAT_MODE_ONE -> R.drawable.rounded_repeat_one_24 - Player.REPEAT_MODE_ALL -> R.drawable.rounded_repeat_24 - else -> R.drawable.rounded_repeat_24 - } - ToggleSegmentButton( - modifier = commonModifier, - active = repeatActive, - activeColor = LocalMaterialTheme.current.secondaryFixed, - activeCornerRadius = rowCorners, - activeContentColor = LocalMaterialTheme.current.onSecondaryFixed, - inactiveColor = inactiveBg, - inactiveContentColor = inactiveContentColor, - onClick = onRepeatToggle, - iconId = repeatIcon, - contentDesc = "Repeat" - ) - ToggleSegmentButton( - modifier = commonModifier, - active = isFavorite, - activeColor = LocalMaterialTheme.current.tertiaryFixed, - activeCornerRadius = rowCorners, - activeContentColor = LocalMaterialTheme.current.onTertiaryFixed, - inactiveColor = inactiveBg, - inactiveContentColor = inactiveContentColor, - onClick = onFavoriteToggle, - iconId = if (isFavorite) R.drawable.round_favorite_24 else R.drawable.rounded_favorite_24, - contentDesc = "Favorite" - ) - } - } -} + exit = fadeOut(animationSpec = tween(220, easing = diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/ui/theme/LiquidGlass.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/ui/theme/LiquidGlass.kt new file mode 100644 index 00000000..5ed3f511 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/ui/theme/LiquidGlass.kt @@ -0,0 +1,92 @@ +package com.lostf1sh.pixelplayeross.ui.theme + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Lightweight iOS-inspired Liquid Glass treatment for Compose surfaces. + * Uses layered translucency, a moving specular highlight and a soft rim, + * without requiring platform-specific blur APIs. + */ +@Composable +fun Modifier.liquidGlass( + shape: Shape = RoundedCornerShape(24.dp), + borderWidth: Dp = 1.dp, + tintColor: Color = MaterialTheme.colorScheme.surfaceContainerHighest, + tintAlpha: Float = 0.45f, + animated: Boolean = true +): Modifier { + val transition = rememberInfiniteTransition(label = "liquidGlass") + val sheen by transition.animateFloat( + initialValue = -0.35f, + targetValue = 1.35f, + animationSpec = infiniteRepeatable( + animation = tween(4200), + repeatMode = RepeatMode.Restart + ), + label = "glassSheen" + ) + val sheenStart = if (animated) sheen else 0.15f + val surface = MaterialTheme.colorScheme.surface + val surfaceTint = MaterialTheme.colorScheme.surfaceTint + val outline = MaterialTheme.colorScheme.outlineVariant + + return this + .clip(shape) + .background( + color = tintColor.copy(alpha = tintAlpha.coerceIn(0f, 1f)), + shape = shape + ) + .background( + brush = Brush.linearGradient( + colors = listOf( + Color.White.copy(alpha = 0.22f), + surface.copy(alpha = 0.10f), + surfaceTint.copy(alpha = 0.12f), + Color.White.copy(alpha = 0.06f) + ), + start = androidx.compose.ui.geometry.Offset(0f, 0f), + end = androidx.compose.ui.geometry.Offset(900f, 900f) + ), + shape = shape + ) + .background( + brush = Brush.linearGradient( + colors = listOf( + Color.Transparent, + Color.White.copy(alpha = 0.16f), + Color.Transparent + ), + start = androidx.compose.ui.geometry.Offset(sheenStart * 900f, 0f), + end = androidx.compose.ui.geometry.Offset((sheenStart + 0.35f) * 900f, 900f) + ), + shape = shape + ) + .border( + width = borderWidth, + brush = Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = 0.55f), + outline.copy(alpha = 0.24f), + Color.White.copy(alpha = 0.12f) + ) + ), + shape = shape + ) +}