add the mini player and now playing screen

This commit is contained in:
austinried
2026-10-04 00:29:51 +00:00
parent 592c27c99c
commit ed9d74d5ae
9 changed files with 547 additions and 64 deletions
+122 -54
View File
@@ -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<PlaybackController>()
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 }
}
@@ -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<Song>,
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) },
)
}
}
}
}
@@ -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())
}
}
}
@@ -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 =
@@ -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<SongListItem>,
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) },
)
}
}
}
@@ -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())
}
}
}
@@ -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),
)
@@ -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),
)
}
}
}
}
}
@@ -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)
}