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
@@ -1,13 +1,30 @@
package com.subtracks.ui package com.subtracks.ui
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -17,15 +34,19 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.library.AlbumDetailRoute import com.subtracks.ui.library.AlbumDetailRoute
import com.subtracks.ui.library.LibraryRoute import com.subtracks.ui.library.LibraryRoute
import com.subtracks.ui.library.PlaylistDetailRoute 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.AddSourceRoute
import com.subtracks.ui.settings.SettingsRoute import com.subtracks.ui.settings.SettingsRoute
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
class RootViewModel( class RootViewModel(
@@ -39,6 +60,7 @@ class RootViewModel(
} }
private const val NAVIGATION_DURATION_MS = 260 private const val NAVIGATION_DURATION_MS = 260
private const val NOW_PLAYING_DURATION_MS = 300
private object Routes { private object Routes {
const val LIBRARY = "library" const val LIBRARY = "library"
@@ -64,9 +86,29 @@ fun SubtracksRoot(root: RootViewModel = koinViewModel()) {
@Composable @Composable
private fun MainNavigation() { private fun MainNavigation() {
val navController = rememberNavController() val navController = rememberNavController()
val playbackController = koinInject<PlaybackController>()
val playback by playbackController.state.collectAsStateWithLifecycle()
var showingNowPlaying by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) { playbackController.connect() }
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( NavHost(
navController = navController, navController = navController,
modifier =
Modifier
.weight(1f)
.then(
if (playerVisible) {
Modifier.consumeWindowInsets(WindowInsets.navigationBars)
} else {
Modifier
},
),
startDestination = Routes.LIBRARY, startDestination = Routes.LIBRARY,
enterTransition = { enterTransition = {
slideInHorizontally(initialOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + slideInHorizontally(initialOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
@@ -90,6 +132,7 @@ private fun MainNavigation() {
onAlbumClick = { album -> navController.navigate(Routes.album(album.id)) }, onAlbumClick = { album -> navController.navigate(Routes.album(album.id)) },
onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) }, onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) },
onOpenSettings = { navController.navigate(Routes.SETTINGS) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) },
bottomInset = if (playerVisible) 0.dp else navBarInset,
) )
} }
composable(Routes.SETTINGS) { composable(Routes.SETTINGS) {
@@ -123,4 +166,29 @@ private fun MainNavigation() {
) )
} }
} }
if (playerVisible) {
MiniPlayer(
state = playback,
coverArt = playbackController.coverArt(playback.item, thumbnail = true),
onExpand = { showingNowPlaying = true },
onPlayPause = playbackController::togglePlayPause,
onNext = playbackController::next,
)
}
}
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 package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
@@ -28,8 +29,11 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@@ -38,14 +42,19 @@ fun AlbumDetailRoute(
albumId: String, albumId: String,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) }, viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
playbackController: PlaybackController = koinInject(),
) { ) {
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null) val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList()) val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context
AlbumDetailScreen( AlbumDetailScreen(
album = album, album = album,
songs = songs, songs = songs,
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
onBack = onBack, 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>, songs: List<Song>,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit, onBack: () -> Unit,
onSongClick: (Int) -> Unit,
playingSongId: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( Scaffold(
@@ -81,7 +92,7 @@ fun AlbumDetailScreen(
CoverArt( CoverArt(
ref = coverArt(album?.coverArt, false), ref = coverArt(album?.coverArt, false),
name = album?.name.orEmpty(), name = album?.name.orEmpty(),
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(12.dp)), modifier = Modifier.size(120.dp).clip(RoundedCornerShape(2.dp)),
) )
Column { Column {
Text(album?.name.orEmpty(), style = MaterialTheme.typography.titleLarge) 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 package com.subtracks.ui.library
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class AlbumDetailViewModel( class AlbumDetailViewModel(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
albumId: String, private val playbackController: PlaybackController,
private val albumId: String,
) : ViewModel() { ) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull() private val sourceId = libraryRepository.activeSourceId.filterNotNull()
@@ -27,4 +32,10 @@ class AlbumDetailViewModel(
coverArt: String?, coverArt: String?,
thumbnail: Boolean, thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) ): 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.boundsInParent
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -87,6 +88,7 @@ fun LibraryRoute(
onAlbumClick: (Album) -> Unit, onAlbumClick: (Album) -> Unit,
onPlaylistClick: (Playlist) -> Unit, onPlaylistClick: (Playlist) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
bottomInset: Dp,
viewModel: LibraryViewModel = koinViewModel(), viewModel: LibraryViewModel = koinViewModel(),
) { ) {
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) } var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
@@ -95,6 +97,7 @@ fun LibraryRoute(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { selectedTab = it }, onTabSelected = { selectedTab = it },
syncing = syncing, syncing = syncing,
bottomInset = bottomInset,
albums = viewModel.albums.collectAsLazyPagingItems(), albums = viewModel.albums.collectAsLazyPagingItems(),
artists = viewModel.artists.collectAsLazyPagingItems(), artists = viewModel.artists.collectAsLazyPagingItems(),
songs = viewModel.songs.collectAsLazyPagingItems(), songs = viewModel.songs.collectAsLazyPagingItems(),
@@ -122,6 +125,7 @@ fun LibraryScreen(
onSync: () -> Unit, onSync: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
syncing: Boolean = false, syncing: Boolean = false,
bottomInset: Dp = 0.dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val pagerState = val pagerState =
@@ -153,7 +157,6 @@ fun LibraryScreen(
val titleFraction = scrollBehavior.state.collapsedFraction val titleFraction = scrollBehavior.state.collapsedFraction
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() } val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val bottomInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
Column( Column(
modifier = modifier =
@@ -1,5 +1,6 @@
package com.subtracks.ui.library package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -30,8 +31,11 @@ import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey import androidx.paging.compose.itemKey
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@@ -40,13 +44,18 @@ fun PlaylistDetailRoute(
playlistId: String, playlistId: String,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) }, viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
playbackController: PlaybackController = koinInject(),
) { ) {
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null) val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context
PlaylistDetailScreen( PlaylistDetailScreen(
playlist = playlist, playlist = playlist,
songs = viewModel.songs.collectAsLazyPagingItems(), songs = viewModel.songs.collectAsLazyPagingItems(),
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
onBack = onBack, 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>, songs: LazyPagingItems<SongListItem>,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit, onBack: () -> Unit,
onSongClick: (Int) -> Unit,
playingSongId: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( Scaffold(
@@ -82,7 +93,7 @@ fun PlaylistDetailScreen(
CoverArt( CoverArt(
ref = coverArt(playlist?.coverArt, false), ref = coverArt(playlist?.coverArt, false),
name = playlist?.name.orEmpty(), name = playlist?.name.orEmpty(),
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(12.dp)), modifier = Modifier.size(120.dp).clip(RoundedCornerShape(2.dp)),
) )
Column { Column {
Text(playlist?.name.orEmpty(), style = MaterialTheme.typography.titleLarge) 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 -> items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
val item = songs[index] val item = songs[index]
if (item != null) { 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.model.SongListItem
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class PlaylistDetailViewModel( class PlaylistDetailViewModel(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
playlistId: String, private val playbackController: PlaybackController,
private val playlistId: String,
) : ViewModel() { ) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull() private val sourceId = libraryRepository.activeSourceId.filterNotNull()
@@ -33,4 +37,10 @@ class PlaylistDetailViewModel(
coverArt: String?, coverArt: String?,
thumbnail: Boolean, thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) ): 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 package com.subtracks.ui.library
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape 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.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -61,6 +68,7 @@ fun SongRow(
song: Song, song: Song,
coverArtId: String? = null, coverArtId: String? = null,
coverArt: ((String?, Boolean) -> CoverArtRef?)? = null, coverArt: ((String?, Boolean) -> CoverArtRef?)? = null,
isPlaying: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
ListItem( ListItem(
@@ -77,10 +85,33 @@ fun SongRow(
} else { } else {
null 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 = { supportingContent = {
val subtitle = listOfNotNull(song.artist, song.album).joinToString(" • ") 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), 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)
}