From ed9d74d5aec5d5595530b0daa3593c4cd1959abd Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:35:03 +0000 Subject: [PATCH] add the mini player and now playing screen --- .../main/kotlin/com/subtracks/ui/AppRoot.kt | 176 +++++++++----- .../subtracks/ui/library/AlbumDetailScreen.kt | 23 +- .../ui/library/AlbumDetailViewModel.kt | 13 +- .../com/subtracks/ui/library/LibraryScreen.kt | 5 +- .../ui/library/PlaylistDetailScreen.kt | 21 +- .../ui/library/PlaylistDetailViewModel.kt | 12 +- .../com/subtracks/ui/library/SongsContent.kt | 35 ++- .../com/subtracks/ui/playback/MiniPlayer.kt | 112 +++++++++ .../subtracks/ui/playback/NowPlayingScreen.kt | 214 ++++++++++++++++++ 9 files changed, 547 insertions(+), 64 deletions(-) create mode 100644 app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt create mode 100644 app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index ac31d9ca..6a4d5c99 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -1,13 +1,30 @@ package com.subtracks.ui import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope @@ -17,15 +34,19 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.subtracks.data.repo.SourceRepository +import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.LoadingState import com.subtracks.ui.library.AlbumDetailRoute import com.subtracks.ui.library.LibraryRoute import com.subtracks.ui.library.PlaylistDetailRoute +import com.subtracks.ui.playback.MiniPlayer +import com.subtracks.ui.playback.NowPlayingRoute import com.subtracks.ui.settings.AddSourceRoute import com.subtracks.ui.settings.SettingsRoute import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel class RootViewModel( @@ -39,6 +60,7 @@ class RootViewModel( } private const val NAVIGATION_DURATION_MS = 260 +private const val NOW_PLAYING_DURATION_MS = 300 private object Routes { const val LIBRARY = "library" @@ -64,63 +86,109 @@ fun SubtracksRoot(root: RootViewModel = koinViewModel()) { @Composable private fun MainNavigation() { val navController = rememberNavController() + val playbackController = koinInject() + val playback by playbackController.state.collectAsStateWithLifecycle() + var showingNowPlaying by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { playbackController.connect() } - NavHost( - navController = navController, - startDestination = Routes.LIBRARY, - enterTransition = { - slideInHorizontally(initialOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + - fadeIn(tween(NAVIGATION_DURATION_MS)) - }, - exitTransition = { - slideOutHorizontally(targetOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + - fadeOut(tween(NAVIGATION_DURATION_MS)) - }, - popEnterTransition = { - slideInHorizontally(initialOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + - fadeIn(tween(NAVIGATION_DURATION_MS)) - }, - popExitTransition = { - slideOutHorizontally(targetOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + - fadeOut(tween(NAVIGATION_DURATION_MS)) - }, - ) { - composable(Routes.LIBRARY) { - LibraryRoute( - onAlbumClick = { album -> navController.navigate(Routes.album(album.id)) }, - onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) }, - onOpenSettings = { navController.navigate(Routes.SETTINGS) }, - ) + val playerVisible = playback.item != null + val density = LocalDensity.current + val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } + + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + NavHost( + navController = navController, + modifier = + Modifier + .weight(1f) + .then( + if (playerVisible) { + Modifier.consumeWindowInsets(WindowInsets.navigationBars) + } else { + Modifier + }, + ), + startDestination = Routes.LIBRARY, + enterTransition = { + slideInHorizontally(initialOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + + fadeIn(tween(NAVIGATION_DURATION_MS)) + }, + exitTransition = { + slideOutHorizontally(targetOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + + fadeOut(tween(NAVIGATION_DURATION_MS)) + }, + popEnterTransition = { + slideInHorizontally(initialOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + + fadeIn(tween(NAVIGATION_DURATION_MS)) + }, + popExitTransition = { + slideOutHorizontally(targetOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + + fadeOut(tween(NAVIGATION_DURATION_MS)) + }, + ) { + composable(Routes.LIBRARY) { + LibraryRoute( + onAlbumClick = { album -> navController.navigate(Routes.album(album.id)) }, + onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) }, + onOpenSettings = { navController.navigate(Routes.SETTINGS) }, + bottomInset = if (playerVisible) 0.dp else navBarInset, + ) + } + composable(Routes.SETTINGS) { + SettingsRoute( + onAddServer = { navController.navigate(Routes.ADD_SERVER) }, + onBack = { navController.popBackStack() }, + ) + } + composable( + route = Routes.ALBUM_DETAIL, + arguments = listOf(navArgument("albumId") { type = NavType.StringType }), + ) { entry -> + AlbumDetailRoute( + albumId = entry.arguments?.getString("albumId").orEmpty(), + onBack = { navController.popBackStack() }, + ) + } + composable( + route = Routes.PLAYLIST_DETAIL, + arguments = listOf(navArgument("playlistId") { type = NavType.StringType }), + ) { entry -> + PlaylistDetailRoute( + playlistId = entry.arguments?.getString("playlistId").orEmpty(), + onBack = { navController.popBackStack() }, + ) + } + composable(Routes.ADD_SERVER) { + AddSourceRoute( + onSaved = { navController.popBackStack() }, + onBack = { navController.popBackStack() }, + ) + } + } + + if (playerVisible) { + MiniPlayer( + state = playback, + coverArt = playbackController.coverArt(playback.item, thumbnail = true), + onExpand = { showingNowPlaying = true }, + onPlayPause = playbackController::togglePlayPause, + onNext = playbackController::next, + ) + } } - composable(Routes.SETTINGS) { - SettingsRoute( - onAddServer = { navController.navigate(Routes.ADD_SERVER) }, - onBack = { navController.popBackStack() }, - ) - } - composable( - route = Routes.ALBUM_DETAIL, - arguments = listOf(navArgument("albumId") { type = NavType.StringType }), - ) { entry -> - AlbumDetailRoute( - albumId = entry.arguments?.getString("albumId").orEmpty(), - onBack = { navController.popBackStack() }, - ) - } - composable( - route = Routes.PLAYLIST_DETAIL, - arguments = listOf(navArgument("playlistId") { type = NavType.StringType }), - ) { entry -> - PlaylistDetailRoute( - playlistId = entry.arguments?.getString("playlistId").orEmpty(), - onBack = { navController.popBackStack() }, - ) - } - composable(Routes.ADD_SERVER) { - AddSourceRoute( - onSaved = { navController.popBackStack() }, - onBack = { navController.popBackStack() }, + + AnimatedVisibility( + visible = showingNowPlaying, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(NOW_PLAYING_DURATION_MS)), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(NOW_PLAYING_DURATION_MS)), + ) { + NowPlayingRoute( + onBack = { showingNowPlaying = false }, + modifier = Modifier.fillMaxSize(), ) } } + + BackHandler(enabled = showingNowPlaying) { showingNowPlaying = false } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index 698d056c..6c3936b8 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -1,5 +1,6 @@ package com.subtracks.ui.library +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -8,7 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack @@ -28,8 +29,11 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.subtracks.data.model.Album import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.QueueKind import com.subtracks.data.model.Song +import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.CoverArt +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -38,14 +42,19 @@ fun AlbumDetailRoute( albumId: String, onBack: () -> Unit, viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) }, + playbackController: PlaybackController = koinInject(), ) { val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null) val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList()) + val playback by playbackController.state.collectAsStateWithLifecycle() + val context = playback.context AlbumDetailScreen( album = album, songs = songs, coverArt = viewModel::coverArt, onBack = onBack, + onSongClick = viewModel::play, + playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId }, ) } @@ -56,6 +65,8 @@ fun AlbumDetailScreen( songs: List, coverArt: (String?, Boolean) -> CoverArtRef?, onBack: () -> Unit, + onSongClick: (Int) -> Unit, + playingSongId: String? = null, modifier: Modifier = Modifier, ) { Scaffold( @@ -81,7 +92,7 @@ fun AlbumDetailScreen( CoverArt( ref = coverArt(album?.coverArt, false), name = album?.name.orEmpty(), - modifier = Modifier.size(120.dp).clip(RoundedCornerShape(12.dp)), + modifier = Modifier.size(120.dp).clip(RoundedCornerShape(2.dp)), ) Column { Text(album?.name.orEmpty(), style = MaterialTheme.typography.titleLarge) @@ -98,7 +109,13 @@ fun AlbumDetailScreen( } } } - items(songs, key = { it.id }) { song -> SongRow(song) } + itemsIndexed(songs, key = { _, song -> song.id }) { index, song -> + SongRow( + song = song, + isPlaying = song.id == playingSongId, + modifier = Modifier.clickable { onSongClick(index) }, + ) + } } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt index e9259280..8ee3fae7 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt @@ -1,21 +1,26 @@ package com.subtracks.ui.library import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.subtracks.data.model.Album import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.Song import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository +import com.subtracks.playback.PlaybackController import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) class AlbumDetailViewModel( private val libraryRepository: LibraryRepository, private val sourceRepository: SourceRepository, - albumId: String, + private val playbackController: PlaybackController, + private val albumId: String, ) : ViewModel() { private val sourceId = libraryRepository.activeSourceId.filterNotNull() @@ -27,4 +32,10 @@ class AlbumDetailViewModel( coverArt: String?, thumbnail: Boolean, ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) + + fun play(startIndex: Int) { + viewModelScope.launch { + playbackController.playAlbum(sourceId.first(), albumId, startIndex.toLong()) + } + } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index 9b81686f..418cc4de 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.boundsInParent import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -87,6 +88,7 @@ fun LibraryRoute( onAlbumClick: (Album) -> Unit, onPlaylistClick: (Playlist) -> Unit, onOpenSettings: () -> Unit, + bottomInset: Dp, viewModel: LibraryViewModel = koinViewModel(), ) { var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) } @@ -95,6 +97,7 @@ fun LibraryRoute( selectedTab = selectedTab, onTabSelected = { selectedTab = it }, syncing = syncing, + bottomInset = bottomInset, albums = viewModel.albums.collectAsLazyPagingItems(), artists = viewModel.artists.collectAsLazyPagingItems(), songs = viewModel.songs.collectAsLazyPagingItems(), @@ -122,6 +125,7 @@ fun LibraryScreen( onSync: () -> Unit, onOpenSettings: () -> Unit, syncing: Boolean = false, + bottomInset: Dp = 0.dp, modifier: Modifier = Modifier, ) { val pagerState = @@ -153,7 +157,6 @@ fun LibraryScreen( val titleFraction = scrollBehavior.state.collapsedFraction val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() } - val bottomInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } Column( modifier = diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt index ece74031..7990ed32 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -1,5 +1,6 @@ package com.subtracks.ui.library +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -30,8 +31,11 @@ import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.Playlist +import com.subtracks.data.model.QueueKind import com.subtracks.data.model.SongListItem +import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.CoverArt +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -40,13 +44,18 @@ fun PlaylistDetailRoute( playlistId: String, onBack: () -> Unit, viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) }, + playbackController: PlaybackController = koinInject(), ) { val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null) + val playback by playbackController.state.collectAsStateWithLifecycle() + val context = playback.context PlaylistDetailScreen( playlist = playlist, songs = viewModel.songs.collectAsLazyPagingItems(), coverArt = viewModel::coverArt, onBack = onBack, + onSongClick = viewModel::play, + playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId }, ) } @@ -57,6 +66,8 @@ fun PlaylistDetailScreen( songs: LazyPagingItems, coverArt: (String?, Boolean) -> CoverArtRef?, onBack: () -> Unit, + onSongClick: (Int) -> Unit, + playingSongId: String? = null, modifier: Modifier = Modifier, ) { Scaffold( @@ -82,7 +93,7 @@ fun PlaylistDetailScreen( CoverArt( ref = coverArt(playlist?.coverArt, false), name = playlist?.name.orEmpty(), - modifier = Modifier.size(120.dp).clip(RoundedCornerShape(12.dp)), + modifier = Modifier.size(120.dp).clip(RoundedCornerShape(2.dp)), ) Column { Text(playlist?.name.orEmpty(), style = MaterialTheme.typography.titleLarge) @@ -97,7 +108,13 @@ fun PlaylistDetailScreen( items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index -> val item = songs[index] if (item != null) { - SongRow(song = item.song, coverArtId = item.coverArt, coverArt = coverArt) + SongRow( + song = item.song, + coverArtId = item.coverArt, + coverArt = coverArt, + isPlaying = item.song.id == playingSongId, + modifier = Modifier.clickable { onSongClick(index) }, + ) } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt index 6b980a63..57763370 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt @@ -9,16 +9,20 @@ import com.subtracks.data.model.Playlist import com.subtracks.data.model.SongListItem import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository +import com.subtracks.playback.PlaybackController import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) class PlaylistDetailViewModel( private val libraryRepository: LibraryRepository, private val sourceRepository: SourceRepository, - playlistId: String, + private val playbackController: PlaybackController, + private val playlistId: String, ) : ViewModel() { private val sourceId = libraryRepository.activeSourceId.filterNotNull() @@ -33,4 +37,10 @@ class PlaylistDetailViewModel( coverArt: String?, thumbnail: Boolean, ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) + + fun play(startIndex: Int) { + viewModelScope.launch { + playbackController.playPlaylist(sourceId.first(), playlistId, startIndex.toLong()) + } + } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt index 5a30164b..3d52a9e1 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt @@ -1,14 +1,21 @@ package com.subtracks.ui.library +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -61,6 +68,7 @@ fun SongRow( song: Song, coverArtId: String? = null, coverArt: ((String?, Boolean) -> CoverArtRef?)? = null, + isPlaying: Boolean = false, modifier: Modifier = Modifier, ) { ListItem( @@ -77,10 +85,33 @@ fun SongRow( } else { null }, - headlineContent = { Text(song.title) }, + headlineContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (isPlaying) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = "Playing", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + Text( + text = song.title, + color = if (isPlaying) MaterialTheme.colorScheme.primary else Color.Unspecified, + ) + } + }, supportingContent = { val subtitle = listOfNotNull(song.artist, song.album).joinToString(" • ") - if (subtitle.isNotEmpty()) Text(subtitle) + if (subtitle.isNotEmpty()) { + Text( + text = subtitle, + color = if (isPlaying) MaterialTheme.colorScheme.primary else Color.Unspecified, + ) + } }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt b/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt new file mode 100644 index 00000000..df50e816 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt @@ -0,0 +1,112 @@ +package com.subtracks.ui.playback + +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.SkipNext +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.subtracks.data.model.CoverArtRef +import com.subtracks.playback.PlaybackState +import com.subtracks.ui.components.CoverArt + +@Composable +fun MiniPlayer( + state: PlaybackState, + coverArt: CoverArtRef?, + onExpand: () -> Unit, + onPlayPause: () -> Unit, + onNext: () -> Unit, + modifier: Modifier = Modifier, +) { + val item = state.item ?: return + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + modifier = + modifier.fillMaxWidth().clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onExpand, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(start = 12.dp, end = 4.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CoverArt( + ref = coverArt, + name = item.title, + modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)), + ) + Column(Modifier.weight(1f)) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + modifier = Modifier.basicMarquee(), + ) + Text( + text = listOfNotNull(item.artist, item.album).joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.basicMarquee(), + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onPlayPause, modifier = Modifier.size(56.dp)) { + if (state.isBuffering && state.isPlaying) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onSurface, + strokeWidth = 3.dp, + modifier = Modifier.size(28.dp), + ) + } else { + Icon( + imageVector = if (state.isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, + contentDescription = if (state.isPlaying) "Pause" else "Play", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(42.dp), + ) + } + } + IconButton(onClick = onNext, enabled = state.hasNext, modifier = Modifier.size(56.dp)) { + Icon( + imageVector = Icons.Rounded.SkipNext, + contentDescription = "Next", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(42.dp), + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt new file mode 100644 index 00000000..69926a70 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -0,0 +1,214 @@ +package com.subtracks.ui.playback + +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.PauseCircle +import androidx.compose.material.icons.rounded.PlayCircle +import androidx.compose.material.icons.rounded.SkipNext +import androidx.compose.material.icons.rounded.SkipPrevious +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.subtracks.data.model.CoverArtRef +import com.subtracks.playback.PlaybackController +import com.subtracks.playback.PlaybackState +import com.subtracks.ui.components.CoverArt +import org.koin.compose.koinInject + +@Composable +fun NowPlayingRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + controller: PlaybackController = koinInject(), +) { + val state by controller.state.collectAsStateWithLifecycle() + NowPlayingScreen( + state = state, + coverArt = controller.coverArt(state.item), + onBack = onBack, + onPlayPause = controller::togglePlayPause, + onNext = controller::next, + onPrevious = controller::previous, + onSeek = controller::seekTo, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NowPlayingScreen( + state: PlaybackState, + coverArt: CoverArtRef?, + onBack: () -> Unit, + onPlayPause: () -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, + onSeek: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + val playButtonSize = 96.dp + val playCircleDiameter = playButtonSize * 20f / 24f + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text("Now playing") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier.weight(1f).fillMaxWidth().padding(top = 8.dp), + contentAlignment = Alignment.Center, + ) { + CoverArt( + ref = coverArt, + name = state.item?.title.orEmpty(), + modifier = + Modifier + .fillMaxHeight() + .aspectRatio(1f) + .clip(RoundedCornerShape(2.dp)), + ) + } + Text( + text = state.item?.title.orEmpty(), + style = MaterialTheme.typography.headlineSmall, + maxLines = 1, + modifier = Modifier.padding(top = 28.dp).basicMarquee(), + ) + Text( + text = listOfNotNull(state.item?.artist, state.item?.album).joinToString(" • "), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.padding(top = 6.dp).basicMarquee(), + ) + var dragging by remember { mutableStateOf(false) } + var dragPosition by remember { mutableFloatStateOf(0f) } + Slider( + value = if (dragging) dragPosition else state.positionMs.toFloat(), + onValueChange = { + dragging = true + dragPosition = it + }, + onValueChangeFinished = { + onSeek(dragPosition.toLong()) + dragging = false + }, + valueRange = 0f..state.durationMs.toFloat().coerceAtLeast(1f), + enabled = state.durationMs > 0, + modifier = Modifier.padding(top = 28.dp), + ) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(formatTime(state.positionMs), style = MaterialTheme.typography.bodySmall) + Text(formatTime(state.durationMs), style = MaterialTheme.typography.bodySmall) + } + if (state.error != null) { + Text( + text = state.error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 8.dp), + ) + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp, bottom = 32.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onPrevious, enabled = state.hasPrevious, modifier = Modifier.size(64.dp)) { + Icon( + imageVector = Icons.Rounded.SkipPrevious, + contentDescription = "Previous", + modifier = Modifier.size(48.dp), + ) + } + IconButton(onClick = onPlayPause, modifier = Modifier.padding(horizontal = 20.dp).size(playButtonSize)) { + if (state.isBuffering && state.isPlaying) { + Box( + modifier = + Modifier + .size(playCircleDiameter) + .background(MaterialTheme.colorScheme.onBackground, CircleShape), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.background, + strokeWidth = 4.dp, + modifier = Modifier.size(playCircleDiameter * 0.65f), + ) + } + } else { + Icon( + imageVector = if (state.isPlaying) Icons.Rounded.PauseCircle else Icons.Rounded.PlayCircle, + contentDescription = if (state.isPlaying) "Pause" else "Play", + modifier = Modifier.size(playButtonSize), + ) + } + } + IconButton(onClick = onNext, enabled = state.hasNext, modifier = Modifier.size(64.dp)) { + Icon( + imageVector = Icons.Rounded.SkipNext, + contentDescription = "Next", + modifier = Modifier.size(48.dp), + ) + } + } + } + } +} + +private fun formatTime(milliseconds: Long): String { + val totalSeconds = (milliseconds / 1000).coerceAtLeast(0) + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "%d:%02d".format(minutes, seconds) +}